diff --git a/.claude/skills/e2e/SKILL.md b/.claude/skills/e2e/SKILL.md index ed87ec4c6..4afe22829 100644 --- a/.claude/skills/e2e/SKILL.md +++ b/.claude/skills/e2e/SKILL.md @@ -82,10 +82,10 @@ pnpm exec playwright show-trace test-results//trace.zip ## Test pattern reference -Every test follows this pattern — use these imports from `~/utils/playwright`, NOT raw Playwright APIs: +Every test follows this pattern — use these imports from `./helpers/playwright`, NOT raw Playwright APIs: ```typescript -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; +import { expect, impersonate, navigate, seed, test } from "./helpers/playwright"; test.describe("Feature", () => { test("does something", async ({ page }) => { @@ -104,7 +104,7 @@ Key rules: - Use `seed(page, variation?)` to reset the database. Available variations: DEFAULT, NO_TOURNAMENT_TEAMS, REG_OPEN, SMALL_SOS, NZAP_IN_TEAM, NO_SCRIMS, NO_SQ_GROUPS - Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID) - Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead -- Import `test` from `~/utils/playwright` (not from `@playwright/test`) — it includes worker port fixtures +- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures ## Environment variables diff --git a/AGENTS.md b/AGENTS.md index 93a155ec0..e6dfd8792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ - always use named exports - Remeda is the utility library of choice - date-fns should be used for date related logic +- do not use `forEach`, prefer `for...of` ## React @@ -47,6 +48,7 @@ - one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css` - clsx library is used for conditional class names - prefer using [CSS variables](./app/styles/vars.css) for theming +- for any CSS variable used, make sure it is defined either locally or in the `vars.css` file - for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class - use CSS nesting with the `&` selector to group related selectors (pseudo-classes, pseudo-elements, child selectors, attribute selectors) under their parent instead of repeating the parent selector diff --git a/README.md b/README.md index d3e757da5..d46c337ac 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Another key objective is to bridge the gap between casual and competitive player - [Git](https://git-scm.com/) - [Node.js v22](https://nodejs.org/en) +- [pnpm](https://pnpm.io/installation) Optionally [nvm](https://github.com/nvm-sh/nvm) can be convenient for managing multiple Node.js installs @@ -66,6 +67,7 @@ First verify you have Node.js and git installed: ```bash node --version git --version +pnpm --version ``` You should see something like: @@ -73,6 +75,7 @@ You should see something like: ``` v22.13.0 git version 2.39.5 (Apple Git-154) +10.33.0 ``` (if not then go back to "Prerequisites" and install what is missing) diff --git a/app/browser-test-setup.ts b/app/browser-test-setup.ts index a0ca8c11f..ad31b411c 100644 --- a/app/browser-test-setup.ts +++ b/app/browser-test-setup.ts @@ -15,5 +15,4 @@ i18next.use(initReactI18next).init({ ...config, lng: "en", resources, - showSupportNotice: false, }); diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index ae37a5669..26c9f6ed8 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -128,7 +128,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { {isHydrated ? formatDate(databaseTimestampToDate(updatedAt), { day: "numeric", - month: "long", + month: "numeric", year: "numeric", }) : "t"} diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index f83dd8cce..3fe9fc542 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import * as React from "react"; import { type AxisOptions, Chart as ReactChart } from "react-charts"; import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer"; -import { useTranslation } from "react-i18next"; import { Theme, useTheme } from "~/features/theme/core/provider"; import { useHydrated } from "~/hooks/useHydrated"; import { useTimeFormat } from "~/hooks/useTimeFormat"; @@ -23,9 +22,9 @@ export default function Chart({ valueSuffix?: string; xAxis: "linear" | "localTime"; }) { - const { i18n } = useTranslation(); const theme = useTheme(); const isHydrated = useHydrated(); + const { formatDate } = useTimeFormat(); const primaryAxis = React.useMemo< AxisOptions<(typeof options)[number]["data"][number]> @@ -38,7 +37,7 @@ export default function Chart({ formatters: { scale: (val: any) => { if (val instanceof Date) { - return val.toLocaleDateString(i18n.language, { + return formatDate(val, { day: "numeric", month: "numeric", }); @@ -48,7 +47,7 @@ export default function Chart({ }, }, }), - [i18n.language, xAxis], + [formatDate, xAxis], ); const secondaryAxes = React.useMemo< @@ -117,7 +116,7 @@ function ChartTooltip({ return formatDate(primaryValue, { weekday: "short", day: "numeric", - month: "long", + month: "numeric", }); } diff --git a/app/components/EventsList.tsx b/app/components/EventsList.tsx index 693141947..a8b57200b 100644 --- a/app/components/EventsList.tsx +++ b/app/components/EventsList.tsx @@ -2,6 +2,7 @@ import { isToday, isTomorrow } from "date-fns"; import { useTranslation } from "react-i18next"; import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server"; import { useHydrated } from "~/hooks/useHydrated"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import styles from "./EventsList.module.css"; import { Placeholder } from "./Placeholder"; import { ListLink } from "./SideNav"; @@ -14,6 +15,7 @@ export function EventsList({ onClick?: () => void; }) { const { t, i18n } = useTranslation(["front"]); + const { formatDate, formatTime } = useTimeFormat(); const isHydrated = useHydrated(); if (events.length === 0) { @@ -48,20 +50,13 @@ export function EventsList({ const str = rtf.format(1, "day"); return str.charAt(0).toUpperCase() + str.slice(1); } - return date.toLocaleDateString(i18n.language, { + return formatDate(date, { weekday: "long", - month: "short", + month: "numeric", day: "numeric", }); }; - const formatTime = (date: Date) => { - return date.toLocaleTimeString(i18n.language, { - hour: "numeric", - minute: "2-digit", - }); - }; - const groupedEvents = events.reduce>( (acc, event) => { const key = getDayKey(event.startTime); diff --git a/app/components/Label.module.css b/app/components/Label.module.css index 5778cbb45..15db8c10c 100644 --- a/app/components/Label.module.css +++ b/app/components/Label.module.css @@ -10,6 +10,7 @@ & > label { margin: 0; + text-box: trim-start cap alphabetic; } } diff --git a/app/components/RelativeTime.tsx b/app/components/RelativeTime.tsx index d86ad3eeb..bbfff6edc 100644 --- a/app/components/RelativeTime.tsx +++ b/app/components/RelativeTime.tsx @@ -20,7 +20,7 @@ export function RelativeTime({ hour: "numeric", minute: "numeric", day: "numeric", - month: "long", + month: "numeric", timeZoneName: "short", }) : undefined diff --git a/app/components/StreamListItems.tsx b/app/components/StreamListItems.tsx index da3743d45..cc1917c71 100644 --- a/app/components/StreamListItems.tsx +++ b/app/components/StreamListItems.tsx @@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next"; import { useFetcher } from "react-router"; import type { SidebarStream } from "~/features/core/streams/streams.server"; import { useHydrated } from "~/hooks/useHydrated"; -import type { LanguageCode } from "~/modules/i18n/config"; -import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; +import { databaseTimestampToDate } from "~/utils/dates"; import { navIconUrl, tournamentRegisterPage } from "~/utils/urls"; import { Image } from "./Image"; import { ListLink } from "./SideNav"; @@ -25,14 +25,12 @@ export function StreamListItems({ savedTournamentIds?: number[]; }) { const { t, i18n } = useTranslation(["front"]); + const { formatDateTime, formatTime, formatDistanceToNow } = useTimeFormat(); const isHydrated = useHydrated(); const formatRelativeDate = (timestamp: number) => { const date = new Date(timestamp * 1000); - const timeStr = date.toLocaleTimeString(i18n.language, { - hour: "numeric", - minute: "2-digit", - }); + const timeStr = formatTime(date); if (isToday(date)) { const rtf = new Intl.RelativeTimeFormat(i18n.language, { @@ -49,8 +47,8 @@ export function StreamListItems({ return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`; } - return date.toLocaleDateString(i18n.language, { - month: "short", + return formatDateTime(date, { + month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit", @@ -101,7 +99,6 @@ export function StreamListItems({ ) : ( formatDistanceToNow(startsAtDate, { addSuffix: true, - language: i18n.language as LanguageCode, }) ) } diff --git a/app/components/TimePopover.tsx b/app/components/TimePopover.tsx index c309ced9d..259353792 100644 --- a/app/components/TimePopover.tsx +++ b/app/components/TimePopover.tsx @@ -16,7 +16,7 @@ export default function TimePopover({ minute: "numeric", hour: "numeric", day: "numeric", - month: "long", + month: "numeric", }, underline = true, className, diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index 56fbdb9a1..1744c06bd 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -28,6 +28,7 @@ export interface SendouButtonProps shape?: "circle" | "square"; icon?: JSX.Element; children?: React.ReactNode; + testId?: string; } export function SendouButton({ @@ -37,10 +38,12 @@ export function SendouButton({ shape, className, icon, + testId, ...rest }: SendouButtonProps) { return ( diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 86a27c8c9..63b23f38f 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -54,14 +54,18 @@ export interface SendouMenuItemProps extends MenuItemProps { export function SendouMenuSection({ children, headerText, + headerClassName, }: { children: React.ReactNode; - headerText?: string; + headerText?: React.ReactNode; + headerClassName?: string; }) { return (
{headerText ? ( -
{headerText}
+
+ {headerText} +
) : null} {children}
diff --git a/app/components/elements/TournamentSearch.tsx b/app/components/elements/TournamentSearch.tsx index e6e3efde9..db4d6da1d 100644 --- a/app/components/elements/TournamentSearch.tsx +++ b/app/components/elements/TournamentSearch.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { format, sub } from "date-fns"; +import { sub } from "date-fns"; import { ChevronsUpDown, Search, X } from "lucide-react"; import * as React from "react"; import { @@ -21,6 +21,7 @@ import { useDebounce } from "react-use"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouLabel } from "~/components/elements/Label"; import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import { databaseTimestampToDate } from "~/utils/dates"; import selectStyles from "./Select.module.css"; @@ -150,6 +151,7 @@ function TournamentItem({ }; }) { const { t } = useTranslation(["common"]); + const { formatDate } = useTimeFormat(); if (typeof item.id === "string") { return ( @@ -167,7 +169,11 @@ function TournamentItem({ const additionalText = () => { const date = databaseTimestampToDate(item.startTime); - return format(date, "MMM d, yyyy"); + return formatDate(date, { + day: "numeric", + month: "numeric", + year: "numeric", + }); }; return ( @@ -185,11 +191,9 @@ function TournamentItem({
{item.name} - {additionalText() ? ( -
- {additionalText()} -
- ) : null} +
+ {additionalText()} +
); diff --git a/app/components/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx index f3826ecff..5eb90a9ec 100644 --- a/app/components/layout/ChatSidebar.tsx +++ b/app/components/layout/ChatSidebar.tsx @@ -2,7 +2,12 @@ import clsx from "clsx"; import { ArrowLeft, MessageSquare, X } from "lucide-react"; import { Button } from "react-aria-components"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; +import { Link, useFetcher } from "react-router"; +import { useCurrentRouteChatCode } from "~/features/chat/ChatProvider"; +import { + extractRoomLink, + isMatchRoomUrl, +} from "~/features/chat/chat-constants"; import { resolveDatePlaceholders } from "~/features/chat/chat-utils"; import { Chat } from "~/features/chat/components/Chat"; import { useChatContext } from "~/features/chat/useChatContext"; @@ -62,8 +67,18 @@ function RoomList({ onClose }: { onClose?: () => void }) { const chatContext = useChatContext()!; const { formatDateTime } = useTimeFormat(); - const nonExpiredRooms = chatContext.rooms - .filter((room) => room.expiresAt > Date.now()) + const rawRouteChatCode = useCurrentRouteChatCode(); + const routeChatCodes = rawRouteChatCode + ? Array.isArray(rawRouteChatCode) + ? rawRouteChatCode + : [rawRouteChatCode] + : []; + + const visibleRooms = chatContext.rooms + .filter( + (room) => + room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode), + ) .sort((a, b) => { if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1; const aRecency = a.lastMessageTimestamp || a.createdAt; @@ -75,12 +90,12 @@ function RoomList({ onClose }: { onClose?: () => void }) {
- {nonExpiredRooms.length === 0 ? ( + {visibleRooms.length === 0 ? (
{t("common:chat.sidebar.noActiveChats")}
) : ( - nonExpiredRooms.map((room) => { + visibleRooms.map((room) => { const unread = chatContext.unreadCounts[room.chatCode] ?? 0; return ( @@ -114,7 +129,7 @@ function RoomList({ onClose }: { onClose?: () => void }) { > {resolveDatePlaceholders(room.header, (d) => formatDateTime(d, { - month: "short", + month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", @@ -154,6 +169,7 @@ function ChatView({ onClose }: { onClose?: () => void }) { .filter(([code]) => code !== activeRoom) .reduce((sum, [, count]) => sum + count, 0); + const roomLinkFetcher = useFetcher(); const room = chatContext.rooms.find((r) => r.chatCode === activeRoom); const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now()); const messages = chatContext.messagesForRoom(activeRoom); @@ -169,9 +185,27 @@ function ChatView({ onClose }: { onClose?: () => void }) { } } + const isMatchRoom = room?.url ? isMatchRoomUrl(room.url) : false; + const chatAdapter = { messages, - send: (contents: string) => chatContext.send(activeRoom, contents), + send: (contents: string) => { + chatContext.send(activeRoom, contents); + + if (isMatchRoom) { + const link = extractRoomLink(contents); + if (link) { + roomLinkFetcher.submit( + { _action: "UPSERT", url: link }, + { + method: "post", + action: "/room", + encType: "application/json", + }, + ); + } + } + }, currentRoom: activeRoom, setCurrentRoom: () => {}, readyState: chatContext.readyState, @@ -202,7 +236,7 @@ function ChatView({ onClose }: { onClose?: () => void }) { room?.header ?? t("common:chat.sidebar.title"), (d) => formatDateTime(d, { - month: "short", + month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index 81805f49e..496d3ab00 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -231,7 +231,10 @@ function GlobalSearchContent({ ); const hasQuery = query.length >= 3; + const fetchedQuery = fetcher.data?.query ?? null; const fetchedType = fetcher.data?.type ?? null; + const isCurrentFetch = + hasQuery && fetchedQuery === query && fetchedType === searchType; const results = hasQuery && fetchedType === searchType ? (fetcher.data?.results ?? []) : []; @@ -388,15 +391,27 @@ function GlobalSearchContent({ className={clsx(styles.listBox, "scrollbar")} aria-label={t("common:search")} onAction={handleSelect} - renderEmptyState={() => - hasQuery ? ( + renderEmptyState={() => { + if (!hasQuery) { + return ( +
+ {t("common:search.hint")} +
+ ); + } + if (!isCurrentFetch) { + return ( +
+ {t("common:search.searching")} +
+ ); + } + return (
{t("common:search.noResults")}
- ) : ( -
{t("common:search.hint")}
- ) - } + ); + }} > {results.map((result) => ( !("staffOnly" in item) || isStaff, + (item) => !("staffOnly" in item) || showStaffOnly, ); return ( diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 9474f3f75..19743203a 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -25,6 +25,7 @@ import { useUser } from "~/features/auth/core/user"; import { useChatContext } from "~/features/chat/useChatContext"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { useHydrated } from "~/hooks/useHydrated"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import type { RootLoaderData } from "~/root"; import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server"; import { @@ -55,12 +56,9 @@ import { TopRightButtons } from "./TopRightButtons"; const MAX_DESKTOP_FRIENDS = 4; -function useTimeFormat() { +function useRelativeDayFormat() { const { i18n } = useTranslation(); - - const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => { - return date.toLocaleTimeString(i18n.language, options); - }; + const { formatTime, formatDateTime } = useTimeFormat(); const formatRelativeDay = (daysFromToday: number) => { const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" }); @@ -70,7 +68,7 @@ function useTimeFormat() { const formatRelativeDate = (timestamp: number) => { const date = new Date(timestamp * 1000); - const timeStr = formatTime(date, { hour: "numeric", minute: "2-digit" }); + const timeStr = formatTime(date); if (isToday(date)) { return `${formatRelativeDay(0)}, ${timeStr}`; @@ -79,15 +77,15 @@ function useTimeFormat() { return `${formatRelativeDay(1)}, ${timeStr}`; } - return date.toLocaleDateString(i18n.language, { - month: "short", + return formatDateTime(date, { + month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit", }); }; - return { formatTime, formatRelativeDate }; + return { formatRelativeDate }; } function useBreadcrumbData() { @@ -215,7 +213,7 @@ export function Layout({ const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {}); const { t } = useTranslation(["front", "common"]); - const { formatRelativeDate } = useTimeFormat(); + const { formatRelativeDate } = useRelativeDayFormat(); const isHydrated = useHydrated(); const location = useLocation(); const headerRef = React.useRef(null); diff --git a/app/components/match-page/MatchActionPickBanTab.module.css b/app/components/match-page/MatchActionPickBanTab.module.css new file mode 100644 index 000000000..d8f0b7194 --- /dev/null +++ b/app/components/match-page/MatchActionPickBanTab.module.css @@ -0,0 +1,197 @@ +.root { + display: grid; + grid-template-columns: 1fr; + grid-template-areas: + "header" + "options" + "prompt" + "submit"; + justify-items: center; + align-items: center; + gap: var(--s-6); + container-type: inline-size; +} + +.title { + grid-area: header; + font-size: var(--font-md); + font-weight: var(--weight-semi); + text-align: center; + text-box: trim-start cap alphabetic; +} + +.options { + grid-area: options; + display: flex; + flex-direction: column; + gap: var(--s-6); + width: 100%; +} + +.prompt { + grid-area: prompt; + margin: 0; + font-size: var(--font-sm); + color: var(--color-text-lighter); + text-align: center; +} + +.verbPick { + color: var(--color-success); + font-weight: var(--weight-semi); +} + +.verbBan { + color: var(--color-error); + font-weight: var(--weight-semi); +} + +.submit { + grid-area: submit; +} + +.waiting { + grid-row: prompt-start / submit-end; + margin: 0; + font-size: var(--font-sm); + color: var(--color-text-lighter); + text-align: center; +} + +.modeGroup { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.divider { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + text-transform: uppercase; + display: flex; + gap: var(--s-2); + + &::before, + &::after { + border-bottom: 2px dotted var(--color-bg-higher); + } +} + +.stageGrid { + --tile-width: 90px; + --tile-height: 50px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--s-4) var(--s-3); +} + +.modeGrid { + --tile-width: 90px; + --tile-height: 90px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--s-4) var(--s-3); +} + +.tileContainer { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + width: var(--tile-width); + text-align: center; +} + +.tileWrapper { + position: relative; + width: var(--tile-width); + height: var(--tile-height); +} + +.tile { + height: var(--tile-height); + width: var(--tile-width); + border: none; + background-color: transparent; + transition: + filter, + opacity 0.2s; + border-radius: var(--radius-box); + cursor: pointer; + + &:active { + transform: none; + } + + &:disabled { + cursor: not-allowed; + } +} + +.stageTile { + background-image: var(--map-image-url); + background-size: cover; +} + +.modeTile { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bg-higher); +} + +.tileSelected { + filter: grayscale(100%); + opacity: 0.4; +} + +.tileIcon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + pointer-events: none; +} + +.tileIconPick { + color: var(--color-success); +} + +.tileIconBan { + color: var(--color-error); +} + +.tileNumber { + position: absolute; + background-color: var(--color-text-accent); + border-radius: 100%; + width: 18px; + height: 18px; + display: grid; + place-items: center; + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + top: -5px; + left: 0; + pointer-events: none; +} + +.tileFrom { + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + text-transform: uppercase; + line-height: 1; + margin-block-start: var(--s-0-5); +} + +.tileLabel { + font-size: var(--font-2xs); + color: var(--color-text-high); + font-weight: var(--weight-semi); + margin-block-start: var(--s-1); +} diff --git a/app/components/match-page/MatchActionPickBanTab.tsx b/app/components/match-page/MatchActionPickBanTab.tsx new file mode 100644 index 000000000..8ac69a0c7 --- /dev/null +++ b/app/components/match-page/MatchActionPickBanTab.tsx @@ -0,0 +1,380 @@ +import clsx from "clsx"; +import { Check, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { shortStageName } from "~/modules/in-game-lists/stage-ids"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { stageImageUrl } from "~/utils/urls"; +import { Divider } from "../Divider"; +import { SendouTabPanel } from "../elements/Tabs"; +import { ModeImage } from "../Image"; +import styles from "./MatchActionPickBanTab.module.css"; +import { TAB_KEYS } from "./MatchTabs"; +import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter"; + +export interface PickBanMapOption { + stageId?: StageId; + mode?: ModeShort; + picker?: "US" | "THEM" | "BOTH"; + nth?: number; +} + +interface PickBanSubmission { + type: "PICK" | "BAN"; + map: PickBanMapOption; +} + +interface MatchActionPickBanTabProps { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + onSubmit?: (data: PickBanSubmission) => void; + isSubmitting?: boolean; + weaponReport?: WeaponReporterProps; + waitingFor?: string; +} + +export function MatchActionPickBanTab({ + options, + type, + onSubmit, + isSubmitting, + weaponReport, + waitingFor, +}: MatchActionPickBanTabProps) { + const { t } = useTranslation(["q", "common", "game-misc"]); + const [selected, setSelected] = useState(); + const isWaiting = waitingFor !== undefined; + + const hasStage = options.every((option) => option.stageId !== undefined); + const hasMode = options.every((option) => option.mode !== undefined); + const layout: "STAGE_BY_MODE" | "STAGE_ONLY" | "MODE_ONLY" = + hasStage && hasMode + ? "STAGE_BY_MODE" + : hasStage + ? "STAGE_ONLY" + : "MODE_ONLY"; + + const titleKey = + layout === "MODE_ONLY" + ? type === "PICK" + ? "q:match.action.pickMode" + : "q:match.action.banMode" + : type === "PICK" + ? "q:match.action.pickStage" + : "q:match.action.banStage"; + + const selectedLabel = (() => { + if (!selected) return null; + const stageName = + selected.stageId !== undefined + ? t(`game-misc:STAGE_${selected.stageId}`) + : null; + const modeName = + selected.mode !== undefined + ? t( + selected.stageId !== undefined + ? `game-misc:MODE_SHORT_${selected.mode}` + : `game-misc:MODE_LONG_${selected.mode}`, + ) + : null; + if (stageName && modeName) return `${stageName} (${modeName})`; + return stageName ?? modeName; + })(); + + return ( + +
+
{t(titleKey)}
+ +
+ {layout === "STAGE_BY_MODE" ? ( + + ) : layout === "STAGE_ONLY" ? ( + + ) : ( + + )} +
+ + {isWaiting ? ( +

+ {t("q:match.action.pickBanWaiting", { teamName: waitingFor })} +

+ ) : ( + <> +

+ {selectedLabel ? ( + <> + + {type === "PICK" + ? t("q:match.action.picking") + : t("q:match.action.banning")} + {" "} + {selectedLabel} + + ) : ( + t("q:match.action.pickBanPrompt") + )} +

+ + { + if (!selected) return; + onSubmit?.({ type, map: selected }); + }} + testId="pick-ban-submit-button" + > + {t("common:actions.submit")} + + + )} +
+ {weaponReport ? : null} +
+ ); +} + +function StageByModeGrid({ + options, + type, + selected, + onSelect, + disabled, +}: { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + selected?: PickBanMapOption; + onSelect: (option: PickBanMapOption) => void; + disabled?: boolean; +}) { + const modesInOrder: ModeShort[] = []; + const byMode = new Map(); + for (const option of options) { + const mode = option.mode!; + if (!byMode.has(mode)) { + byMode.set(mode, []); + modesInOrder.push(mode); + } + byMode.get(mode)!.push(option); + } + + return ( + <> + {modesInOrder.map((mode) => ( +
+ + + +
+ {byMode.get(mode)!.map((option) => ( + onSelect(option)} + disabled={disabled} + /> + ))} +
+
+ ))} + + ); +} + +function StageOnlyGrid({ + options, + type, + selected, + onSelect, + disabled, +}: { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + selected?: PickBanMapOption; + onSelect: (option: PickBanMapOption) => void; + disabled?: boolean; +}) { + return ( +
+ {options.map((option) => ( + onSelect(option)} + disabled={disabled} + /> + ))} +
+ ); +} + +function ModeOnlyGrid({ + options, + type, + selected, + onSelect, + disabled, +}: { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + selected?: PickBanMapOption; + onSelect: (option: PickBanMapOption) => void; + disabled?: boolean; +}) { + return ( +
+ {options.map((option) => ( + onSelect(option)} + disabled={disabled} + /> + ))} +
+ ); +} + +function StageTile({ + option, + type, + isSelected, + onSelect, + disabled, +}: { + option: PickBanMapOption; + type: "PICK" | "BAN"; + isSelected: boolean; + onSelect: () => void; + disabled?: boolean; +}) { + const { t } = useTranslation(["q", "game-misc"]); + + return ( +
+
+
+
+ {shortStageName(t(`game-misc:STAGE_${option.stageId!}`))} +
+ {option.picker ? ( + + {option.picker === "US" + ? t("q:match.action.pickerUs") + : option.picker === "THEM" + ? t("q:match.action.pickerThem") + : t("q:match.action.pickerBoth")} + + ) : null} +
+ ); +} + +function ModeTile({ + option, + type, + isSelected, + onSelect, + disabled, +}: { + option: PickBanMapOption; + type: "PICK" | "BAN"; + isSelected: boolean; + onSelect: () => void; + disabled?: boolean; +}) { + const { t } = useTranslation(["game-misc"]); + + return ( +
+
+ + {isSelected ? ( + type === "PICK" ? ( + + ) : ( + + ) + ) : null} +
+
+ {t(`game-misc:MODE_LONG_${option.mode!}`)} +
+
+ ); +} + +function isSameOption(a: PickBanMapOption, b: PickBanMapOption | undefined) { + if (!b) return false; + return a.stageId === b.stageId && a.mode === b.mode; +} diff --git a/app/components/match-page/MatchActionTab.module.css b/app/components/match-page/MatchActionTab.module.css new file mode 100644 index 000000000..fa53d8ad4 --- /dev/null +++ b/app/components/match-page/MatchActionTab.module.css @@ -0,0 +1,273 @@ +.root { + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-areas: + "header header header" + "actions actions actions" + "selection selection selection" + "submit submit submit"; + justify-items: center; + align-items: center; + gap: var(--s-5); + container-type: inline-size; + + @container (max-width: 599px) { + grid-template-columns: 1fr; + grid-template-areas: + "header" + "actions" + "selection" + "submit"; + } +} + +.withPoints { + grid-template-areas: + "header header header" + "actions actions actions" + "selection selection selection" + "ko ko ko" + "submit submit submit"; + + @container (max-width: 599px) { + grid-template-areas: + "header" + "actions" + "selection" + "ko" + "submit"; + } +} + +.title { + grid-area: header; + font-size: var(--font-md); + font-weight: var(--weight-semi); + text-align: center; + text-box: trim-start cap alphabetic; +} + +.actionButtons { + grid-area: actions; + display: flex; + gap: var(--s-6); + margin-block-start: calc(-1 * var(--s-4)); +} + +.selectionRow { + grid-area: selection; + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-areas: + "alpha stage bravo" + ". text ."; + column-gap: var(--s-4); + row-gap: var(--s-1); + justify-items: center; + align-items: center; + width: 100%; + + @container (max-width: 599px) { + column-gap: var(--s-2); + grid-template-columns: auto 1fr; + grid-template-areas: + "stage alpha" + "stage bravo" + "text ."; + } +} + +.teamRadioContainer { + --label-margin: 0; + width: 100%; + height: 100%; + max-width: 250px; + + @container (max-width: 599px) { + max-width: unset; + } +} + +.alpha { + grid-area: alpha; + justify-self: end; + + @container (max-width: 599px) { + justify-self: stretch; + align-self: end; + } +} + +.bravo { + grid-area: bravo; + justify-self: start; + + @container (max-width: 599px) { + justify-self: stretch; + align-self: start; + } +} + +.stageImageWrapper { + grid-area: stage; + + @container (max-width: 599px) { + align-self: stretch; + width: 90px; + } +} + +.stageImage { + border-radius: var(--radius-box); + display: block; + + @container (max-width: 599px) { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.stageLabel { + grid-area: text; + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-3xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.ko { + grid-area: ko; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-1); +} + +.submit { + grid-area: submit; +} + +.checkCircle { + width: 24px; + height: 24px; + border-radius: 100%; + border: 2px solid var(--color-border-high); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.teamRadio { + display: flex; + align-items: center; + gap: var(--s-3); + padding: var(--s-1-5) var(--s-3); + border-radius: var(--radius-field); + border: 2px solid var(--color-border); + cursor: pointer; + background-color: var(--color-bg-high); + min-width: 160px; + transition: background-color 0.15s; + height: 100%; + + &:hover .checkCircle { + border-color: var(--color-accent-high); + } + + @container (max-width: 599px) { + min-width: unset; + } +} + +.selected { + background-color: var(--color-bg-higher); +} + +.focusVisible { + outline: var(--focus-ring); +} + +.teamAvatarInfo { + display: flex; + align-items: center; + gap: var(--s-1-5); + min-width: 0; +} + +.checkCircleSelected { + background-color: var(--color-accent-high); + border-color: var(--color-accent-high); + color: var(--color-text-inverse); + + & svg { + stroke-width: 3px; + } +} + +.teamInfo { + display: flex; + flex-direction: column; + line-height: 1.3; + min-width: 0; +} + +.teamName { + font-weight: var(--weight-semi); + font-size: var(--font-sm); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.teamNameLong { + font-size: var(--font-2xs); +} + +.teamLabel { + font-size: var(--font-3xs); + font-weight: var(--weight-semi); +} + +.myTeamLabel { + color: var(--color-success-high); +} + +.opponentLabel { + color: var(--color-error-high); +} + +.koLabel { + display: flex; + align-items: center; + gap: var(--s-1-5); + font-weight: var(--weight-semi); + cursor: pointer; +} + +.confirmationRoot { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-4); +} + +.confirmationMessage { + font-weight: var(--weight-semi); + text-align: center; + color: var(--color-warning); + margin-block-end: var(--s-4); +} + +.confirmationButtons { + display: flex; + gap: var(--s-3); + margin-block-start: var(--s-4); +} diff --git a/app/components/match-page/MatchActionTab.tsx b/app/components/match-page/MatchActionTab.tsx new file mode 100644 index 000000000..632ccad79 --- /dev/null +++ b/app/components/match-page/MatchActionTab.tsx @@ -0,0 +1,334 @@ +import clsx from "clsx"; +import { Check } from "lucide-react"; +import type * as React from "react"; +import { useState } from "react"; +import { Radio, RadioGroup } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { useWebHaptics } from "web-haptics/react"; +import { shortStageName } from "~/modules/in-game-lists/stage-ids"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { CommonUser } from "~/utils/kysely.server"; +import { Avatar } from "../Avatar"; +import { SendouButton } from "../elements/Button"; +import { SendouTabPanel } from "../elements/Tabs"; +import { ModeImage, StageImage } from "../Image"; +import styles from "./MatchActionTab.module.css"; +import { TAB_KEYS } from "./MatchTabs"; +import { + MatchTimeline, + type MatchTimelineProps, + type TimelineMap, +} from "./MatchTimeline"; +import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter"; + +const LONG_TEAM_NAME_THRESHOLD = 16; + +interface ActionTabTeam { + id: number; + name: string; + avatar?: string; +} + +interface SetEndingData extends MatchTimelineProps { + currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] }; + setEndingTeamIds: number[]; +} + +interface MatchActionTabProps { + teams: [ActionTabTeam, ActionTabTeam]; + ownTeamId: number | null; + stageId: StageId; + mode: ModeShort; + withPoints: boolean; + onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void; + isSubmitting?: boolean; + setEnding?: SetEndingData; + actionButtons?: React.ReactNode; + weaponReport?: WeaponReporterProps; +} + +export function MatchActionTab({ + teams, + ownTeamId, + stageId, + mode, + withPoints, + onSubmit, + isSubmitting, + setEnding, + actionButtons, + weaponReport, +}: MatchActionTabProps) { + const { t } = useTranslation(["q", "game-misc", "common"]); + const [winnerId, setWinnerId] = useState(null); + const [isKo, setIsKo] = useState(false); + const [confirming, setConfirming] = useState(false); + const { trigger } = useWebHaptics(); + + const canSubmit = winnerId !== null; + + const isOnTeam = + ownTeamId != null && + (teams[0].id === ownTeamId || teams[1].id === ownTeamId); + + const submit = () => { + if (winnerId === null) return; + const submitPoints: [number, number] | undefined = withPoints + ? isKo + ? winnerId === teams[0].id + ? [100, 0] + : [0, 100] + : [0, 0] + : undefined; + onSubmit?.({ winnerId, points: submitPoints }); + }; + + return ( + + {confirming && winnerId !== null && setEnding ? ( + setConfirming(false)} + onConfirm={submit} + /> + ) : ( +
+
{t("q:match.action.selectWinner")}
+ {actionButtons ? ( +
{actionButtons}
+ ) : null} + + { + const selectedId = Number(value); + setWinnerId(selectedId); + + const isEnemySelection = isOnTeam && selectedId !== ownTeamId; + if (isEnemySelection) { + trigger([ + { duration: 40, intensity: 0.7 }, + { delay: 40, duration: 40, intensity: 0.7 }, + { delay: 40, duration: 40, intensity: 0.9 }, + { delay: 40, duration: 50, intensity: 0.6 }, + ]); + } else { + trigger([ + { duration: 30 }, + { delay: 60, duration: 40, intensity: 1 }, + ]); + } + }} + isDisabled={isSubmitting} + aria-label={t("q:match.action.selectWinner")} + className={styles.selectionRow} + > + + +
+ + {shortStageName(t(`game-misc:STAGE_${stageId}`))} +
+ +
+ + {withPoints ? ( +
+ +
+ ) : null} + + { + if (winnerId === null) return; + if (setEnding?.setEndingTeamIds.includes(winnerId)) { + setConfirming(true); + } else { + submit(); + } + }} + className={styles.submit} + testId="report-score-button" + > + {t("common:actions.submit")} + +
+ )} + {weaponReport ? : null} +
+ ); +} + +function SetEndingConfirmation({ + setEnding, + stageId, + mode, + winnerId, + teams, + withPoints, + isKo, + isSubmitting, + onBack, + onConfirm, +}: { + setEnding: SetEndingData; + stageId: StageId; + mode: ModeShort; + winnerId: number; + teams: [ActionTabTeam, ActionTabTeam]; + withPoints: boolean; + isKo: boolean; + isSubmitting?: boolean; + onBack: () => void; + onConfirm: () => void; +}) { + const { t } = useTranslation(["q", "common"]); + + const winnerSide = winnerId === teams[0].id ? "ALPHA" : "BRAVO"; + + const newMap: TimelineMap = { + stageId, + mode, + timestamp: Date.now(), + winner: winnerSide, + rosters: setEnding.currentRosters, + points: withPoints + ? isKo + ? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0] + : [0, 0] + : undefined, + }; + + const updatedScore = { + alpha: setEnding.score.alpha + (winnerSide === "ALPHA" ? 1 : 0), + bravo: setEnding.score.bravo + (winnerSide === "BRAVO" ? 1 : 0), + }; + + return ( +
+
+ {t("q:match.action.confirmSetEnding")} +
+ +
+ + {t("common:actions.confirm")} + + + {t("common:actions.back")} + +
+
+ ); +} + +function TeamRadioOption({ + team, + isOwnTeam, + hideLabel, + className, + testId, +}: { + team: ActionTabTeam; + isOwnTeam: boolean; + hideLabel?: boolean; + className?: string; + testId?: string; +}) { + const { t } = useTranslation(["q"]); + + const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD; + + return ( + + {({ isSelected, isFocusVisible }) => ( + + + {isSelected ? : null} + + + + + + {team.name} + + {hideLabel ? null : ( + + {isOwnTeam + ? t("q:match.action.myTeam") + : t("q:match.action.opponent")} + + )} + + + + )} + + ); +} diff --git a/app/components/match-page/MatchBanner.module.css b/app/components/match-page/MatchBanner.module.css new file mode 100644 index 000000000..536341ab9 --- /dev/null +++ b/app/components/match-page/MatchBanner.module.css @@ -0,0 +1,157 @@ +.root { + --banner-height: 175px; + + display: flex; + flex-direction: column; + gap: var(--s-1-5); + container-type: inline-size; +} + +.banner { + position: relative; + display: grid; + grid-template-columns: max-content 1fr; + grid-template-areas: "map info"; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + width: 100%; + height: var(--banner-height); + border-radius: var(--radius-box); + padding: var(--s-2); + background-image: + linear-gradient( + to top, + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 0), + rgba(0, 0, 0, 0.6) + ), + var(--stage-img); + color: var(--color-text); + + :global(html.light) & { + color: var(--color-text-inverse); + } +} + +.info { + grid-area: info; + justify-self: flex-end; +} + +.map { + grid-area: map; + display: flex; + gap: var(--s-1); +} + +.notice { + position: absolute; + bottom: var(--s-2); + left: 50%; + transform: translateX(-50%); + display: flex; + gap: var(--s-0-5); + align-items: center; + color: var(--color-text-high); + background-color: var(--color-bg-high); + padding: var(--s-0-5) var(--s-1-5); + border-radius: var(--radius-field); + font-size: var(--font-3xs); + font-weight: normal; + height: auto; +} + +.infoBadge { + display: flex; + gap: var(--s-1-5); + align-items: center; + height: auto; + color: inherit; + font-size: inherit; + font-weight: inherit; +} + +.thickText { + font-size: var(--font-md); + font-weight: var(--weight-semi); +} + +.legalIcon { + color: var(--color-success); +} + +.illegalIcon { + color: var(--color-error); +} + +.multiBanner { + display: flex; + padding: 0; + overflow: hidden; + background-image: none; +} + +.segment { + --slant: 13px; + + flex: 1 1 0; + min-width: 0; + height: 100%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-image: + linear-gradient( + to top, + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 0), + rgba(0, 0, 0, 0.6) + ), + var(--stage-img); + clip-path: polygon( + var(--slant) 0, + 100% 0, + calc(100% - var(--slant)) 100%, + 0 100% + ); + margin-inline-start: calc(var(--slant) * -1); + + &:first-child { + clip-path: polygon(0 0, 100% 0, calc(100% - var(--slant)) 100%, 0 100%); + margin-inline-start: 0; + } + + &:last-child { + clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, 0 100%); + } +} + +.iconBanner { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--s-1); + width: 100%; + height: var(--banner-height); + border-radius: var(--radius-box); + background-color: var(--color-bg-higher); +} + +.iconBannerHeader { + font-size: var(--font-md); + font-weight: var(--weight-bold); +} + +.iconBannerSubtitle { + font-size: var(--font-xs); + color: var(--color-text-low); +} + +.iconBannerBottomRight { + position: absolute; + top: var(--s-2); + right: var(--s-2); +} diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx new file mode 100644 index 000000000..06eb5cf64 --- /dev/null +++ b/app/components/match-page/MatchBanner.tsx @@ -0,0 +1,144 @@ +import clsx from "clsx"; +import { Check, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { specialWeaponImageUrl, stageBannerImageUrl } from "~/utils/urls"; +import { ModeImage } from "../Image"; +import styles from "./MatchBanner.module.css"; + +export function MatchBannerContainer({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +interface MatchBannerProps { + stageId: StageId; + mode: ModeShort; + screenLegal?: boolean; + children: React.ReactNode; +} + +export function MatchBanner({ + stageId, + mode, + screenLegal, + children, +}: MatchBannerProps) { + const { t } = useTranslation(["game-misc"]); + + return ( +
+
+ + {t(`game-misc:MODE_SHORT_${mode}`)} {t(`game-misc:STAGE_${stageId}`)} +
+
{children}
+ + {screenLegal !== undefined ? ( + + ) : null} +
+ ); +} + +export function MultiMatchBanner({ stageIds }: { stageIds: StageId[] }) { + return ( +
+ {stageIds.map((stageId, i) => ( +
+ ))} +
+ ); +} + +interface IconBannerProps { + icon: React.ReactNode; + header: string; + subtitle?: string; + screenLegal?: boolean; + topRight?: React.ReactNode; + testId?: string; +} + +export function IconBanner({ + icon, + header, + subtitle, + screenLegal, + topRight, + testId, +}: IconBannerProps) { + return ( +
+ {icon} +
{header}
+ {subtitle ? ( +
{subtitle}
+ ) : null} + {screenLegal !== undefined ? ( + + ) : null} + {topRight ? ( +
{topRight}
+ ) : null} +
+ ); +} + +function ScreenNotice({ screenLegal }: { screenLegal: boolean }) { + const { t } = useTranslation(["weapons", "q"]); + + const imgSize = 18; + + const Icon = screenLegal ? Check : X; + + return ( + + + + + } + > + {screenLegal + ? t("q:match.screen.allowed", { + special: t("weapons:SPECIAL_19"), + }) + : t("q:match.screen.ban", { + special: t("weapons:SPECIAL_19"), + })} + + ); +} diff --git a/app/components/match-page/MatchBannerBottomRow.module.css b/app/components/match-page/MatchBannerBottomRow.module.css new file mode 100644 index 000000000..ecffe5e17 --- /dev/null +++ b/app/components/match-page/MatchBannerBottomRow.module.css @@ -0,0 +1,56 @@ +.root { + display: flex; + justify-content: space-between; + padding-inline: var(--s-1-5); + + @container (max-width: 500px) { + flex-direction: column-reverse; + align-items: center; + gap: var(--s-3); + } +} + +.activeRosters { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.modeProgress { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.mode { + background-color: var(--color-bg-higher); + border-radius: var(--radius-full); + padding: var(--s-1); +} + +.modePlaceholder { + background-color: transparent; + padding: calc(var(--s-1) - 1px); + border: 1px dashed var(--color-bg-higher); + color: var(--color-text-low); + display: flex; + align-items: center; + justify-content: center; +} + +.modeCount { + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.team { + display: flex; + gap: var(--s-1); +} + +.vs { + text-transform: uppercase; + font-size: var(--font-3xs); + font-weight: var(--weight-bold); + color: var(--color-text-high); +} diff --git a/app/components/match-page/MatchBannerBottomRow.tsx b/app/components/match-page/MatchBannerBottomRow.tsx new file mode 100644 index 000000000..3ab1a187c --- /dev/null +++ b/app/components/match-page/MatchBannerBottomRow.tsx @@ -0,0 +1,102 @@ +import clsx from "clsx"; +import { MousePointerClick } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { ModeShort } from "~/modules/in-game-lists/types"; +import type { CommonUser } from "~/utils/kysely.server"; +import { Avatar } from "../Avatar"; +import { ModeImage } from "../Image"; +import styles from "./MatchBannerBottomRow.module.css"; + +interface MatchBannerBottomRowProps { + games: Array<{ mode: ModeShort | null; winner?: "ALPHA" | "BRAVO" }>; + activeRosters: { + alpha: CommonUser[] | null; + bravo: CommonUser[] | null; + } | null; +} + +export function MatchBannerBottomRow({ + games, + activeRosters, +}: MatchBannerBottomRowProps) { + return ( +
+ + +
+ ); +} + +function ModeProgress({ games }: Pick) { + const knownModes = games.flatMap((game) => (game.mode ? [game.mode] : [])); + const allSameMode = + knownModes.length === games.length && + games.length > 1 && + knownModes.every((mode) => mode === knownModes[0]); + + if (allSameMode) { + return ( +
+
+ +
+
×{games.length}
+
+ ); + } + + return ( +
+ {games.map((game, i) => + game.mode ? ( +
+ +
+ ) : ( +
+ +
+ ), + )} +
+ ); +} + +function Roster({ users }: { users: CommonUser[] }) { + return ( +
+ {users.map((user) => ( + + ))} +
+ ); +} + +function ActiveRosters({ + activeRosters, +}: Pick) { + const { t } = useTranslation(["q"]); + + if (!activeRosters?.alpha || !activeRosters.bravo) { + return null; + } + + return ( +
+ +
{t("q:match.banner.vs")}
+ +
+ ); +} diff --git a/app/components/match-page/MatchBannerTopRow.module.css b/app/components/match-page/MatchBannerTopRow.module.css new file mode 100644 index 000000000..aa9445009 --- /dev/null +++ b/app/components/match-page/MatchBannerTopRow.module.css @@ -0,0 +1,15 @@ +.root { + display: flex; + justify-content: space-between; + padding-inline: var(--s-1-5); +} + +.values { + display: flex; + gap: var(--s-2); + font-weight: var(--weight-semi); +} + +.sub { + color: var(--color-text-high); +} diff --git a/app/components/match-page/MatchBannerTopRow.tsx b/app/components/match-page/MatchBannerTopRow.tsx new file mode 100644 index 000000000..ac7c4db43 --- /dev/null +++ b/app/components/match-page/MatchBannerTopRow.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from "react-i18next"; +import { useHydrated } from "~/hooks/useHydrated"; +import styles from "./MatchBannerTopRow.module.css"; + +interface MatchBannerTopRowProps { + score: { + alpha: number; + bravo: number; + isFinal: boolean; + count: number; + bestOf: boolean; + }; + time?: { + currentMinutes: number; + totalMinutes: number; + }; +} + +export function MatchBannerTopRow({ score, time }: MatchBannerTopRowProps) { + return ( +
+ + {time ? : null} +
+ ); +} + +function Score({ score }: { score: MatchBannerTopRowProps["score"] }) { + const { t } = useTranslation(["q"]); + + return ( +
+
+ {score.alpha}-{score.bravo} +
+
+ {score.isFinal + ? t("q:match.banner.final") + : score.bestOf + ? t("q:match.banner.bestOf", { count: score.count }) + : t("q:match.banner.playAll", { count: score.count })} +
+
+ ); +} + +function Timer({ + time, +}: { + time: NonNullable; +}) { + const isHydrated = useHydrated(); + const { i18n } = useTranslation(); + + if (!isHydrated) return null; + + const minuteFormatter = new Intl.NumberFormat(i18n.language, { + style: "unit", + unit: "minute", + unitDisplay: "short", + }); + const hourFormatter = new Intl.NumberFormat(i18n.language, { + style: "unit", + unit: "hour", + unitDisplay: "short", + }); + + const MAX_MINUTES = 60; + const dateTime = (minutes: number) => `PT0H${minutes}M`; + const displayValue = (minutes: number) => + minutes >= MAX_MINUTES + ? `${hourFormatter.format(1)}+` + : minuteFormatter.format(minutes); + + return ( +
+ + +
+ ); +} diff --git a/app/components/match-page/MatchJoinTab.module.css b/app/components/match-page/MatchJoinTab.module.css new file mode 100644 index 000000000..885816d89 --- /dev/null +++ b/app/components/match-page/MatchJoinTab.module.css @@ -0,0 +1,86 @@ +.joinContent { + display: grid; + grid-template-areas: "time x" "qr join"; + gap: var(--s-1) var(--s-4); + justify-content: center; +} + +.joinInfo { + display: flex; + flex-direction: column; + grid-area: join; + gap: var(--s-2); +} + +.infoHeader { + text-transform: uppercase; + color: var(--color-text-high); + font-size: var(--font-2xs); + line-height: 1.1; +} + +.infoValue { + font-size: var(--font-lg); + font-weight: var(--weight-semi); + letter-spacing: 1px; +} + +.qrCodeContainer { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-1); + grid-area: qr; +} + +.roomAge { + grid-area: time; + font-size: var(--font-2xs); + color: var(--color-text-high); + text-align: center; +} + +.qrCode { + background-color: white; + padding: var(--s-2); + border-radius: var(--radius-field); +} + +.joinLink { + font-size: var(--font-2xs); + text-overflow: ellipsis; + overflow-x: hidden; + text-wrap: nowrap; + max-width: 140px; +} + +.qrOverlay { + width: 172px; + height: 172px; + border-radius: var(--radius-field); + padding: var(--s-2); + background-color: var(--color-bg-higher); + grid-area: qr; +} + +.stalePrompt { + display: flex; + gap: var(--s-6); + flex-direction: column; + align-items: center; + justify-content: center; +} + +.staleText { + font-size: var(--font-sm); + color: var(--color-text-high); + text-align: center; +} + +.noRoomHint { + font-size: var(--font-sm); + color: var(--color-text-high); + display: grid; + place-items: center; + text-align: center; +} diff --git a/app/components/match-page/MatchJoinTab.tsx b/app/components/match-page/MatchJoinTab.tsx new file mode 100644 index 000000000..c6c1ad01e --- /dev/null +++ b/app/components/match-page/MatchJoinTab.tsx @@ -0,0 +1,146 @@ +import clsx from "clsx"; +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; +import { Alert } from "~/components/Alert"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; +import { SendouButton } from "../elements/Button"; +import { SendouTabPanel } from "../elements/Tabs"; +import styles from "./MatchJoinTab.module.css"; +import { TAB_KEYS } from "./MatchTabs"; + +interface MatchJoinTabProps { + joinLink?: string; + hostedBy?: string; + pool: string; + pass: string; + showNoSplatnetAlert: boolean; + isStale?: boolean; + staleMinutesAgo?: number; + refreshedAt?: Date; + onConfirmRoom?: () => void; + isConfirming?: boolean; +} + +export function MatchJoinTab({ + joinLink, + hostedBy, + pool, + pass, + showNoSplatnetAlert, + isStale, + staleMinutesAgo, + refreshedAt, + onConfirmRoom, + isConfirming, +}: MatchJoinTabProps) { + const { t } = useTranslation(["q"]); + const { formatDistanceToNow } = useTimeFormat(); + + return ( + +
+ {showNoSplatnetAlert ? ( + + {t("q:match.noSplatnetWarning")} + + ) : null} +
+ {joinLink ? ( + isStale ? ( + + ) : ( + <> + {refreshedAt ? ( +
+ {formatDistanceToNow(refreshedAt, { addSuffix: true })} +
+ ) : null} + + + ) + ) : ( +
+ {t("q:match.room.noRoomHint")} +
+ )} +
+ {hostedBy ? ( + + ) : null} + + +
+
+
+
+ ); +} + +function StaleRoomPrompt({ + minutesAgo, + onConfirm, + isConfirming, +}: { + minutesAgo: number; + onConfirm?: () => void; + isConfirming?: boolean; +}) { + const { t } = useTranslation(["q"]); + + return ( +
+
+ {t("q:match.room.stalePrompt", { minutes: minutesAgo })} +
+ + {t("q:match.room.confirm")} + +
+ ); +} + +function InfoWithHeader({ + header, + value, + testId, +}: { + header: string; + value: string; + testId?: string; +}) { + return ( +
+
{header}
+
+ {value} +
+
+ ); +} diff --git a/app/components/match-page/MatchPage.module.css b/app/components/match-page/MatchPage.module.css new file mode 100644 index 000000000..61c23d8fc --- /dev/null +++ b/app/components/match-page/MatchPage.module.css @@ -0,0 +1,5 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-6); +} diff --git a/app/components/match-page/MatchPage.tsx b/app/components/match-page/MatchPage.tsx new file mode 100644 index 000000000..b377ea5fb --- /dev/null +++ b/app/components/match-page/MatchPage.tsx @@ -0,0 +1,12 @@ +import clsx from "clsx"; +import styles from "./MatchPage.module.css"; + +export function MatchPage({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/app/components/match-page/MatchPageHeader.module.css b/app/components/match-page/MatchPageHeader.module.css new file mode 100644 index 000000000..deabb323b --- /dev/null +++ b/app/components/match-page/MatchPageHeader.module.css @@ -0,0 +1,14 @@ +.root { + display: flex; + justify-content: space-between; +} + +.title { + font-size: var(--font-lg); +} + +.subtitle { + font-weight: var(--weight-bold); + font-size: var(--font-xs); + color: var(--color-text-high); +} diff --git a/app/components/match-page/MatchPageHeader.tsx b/app/components/match-page/MatchPageHeader.tsx new file mode 100644 index 000000000..ac20daaec --- /dev/null +++ b/app/components/match-page/MatchPageHeader.tsx @@ -0,0 +1,21 @@ +import styles from "./MatchPageHeader.module.css"; + +export function MatchPageHeader({ + children, + subtitle, + topRight, +}: { + children: React.ReactNode; + subtitle: string; + topRight?: React.ReactNode; +}) { + return ( +
+
+

{children}

+
{subtitle}
+
+ {topRight ?
{topRight}
: null} +
+ ); +} diff --git a/app/components/match-page/MatchResultTab.tsx b/app/components/match-page/MatchResultTab.tsx new file mode 100644 index 000000000..f803c4fcb --- /dev/null +++ b/app/components/match-page/MatchResultTab.tsx @@ -0,0 +1,16 @@ +import type * as React from "react"; +import { SendouTabPanel } from "../elements/Tabs"; +import { TAB_KEYS } from "./MatchTabs"; +import { MatchTimeline, type MatchTimelineProps } from "./MatchTimeline"; + +export function MatchResultTab({ + children, + ...props +}: MatchTimelineProps & { children?: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css new file mode 100644 index 000000000..cbcd15102 --- /dev/null +++ b/app/components/match-page/MatchRosterTab.module.css @@ -0,0 +1,258 @@ +.rosters { + display: flex; + flex-direction: column; + gap: var(--s-8); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + width: max-content; + max-width: 100%; + margin-inline: auto; +} + +.rostersDivider { + display: none; +} + +@container (width >= 640px) { + .rosters { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--s-4); + width: auto; + max-width: none; + margin-inline: 0; + } + + .rosterColumn { + margin-inline: auto; + width: max-content; + max-width: 100%; + } + + .rostersDivider { + display: block; + background-color: var(--color-border); + width: 1px; + align-self: stretch; + } +} + +.rostersSpacedHeader { + min-height: 45px; + display: flex; + align-items: center; +} + +.rosterMembers { + position: relative; + padding-inline-start: 34px; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--s-2-5); + margin-top: var(--s-2); + + &::before { + content: ""; + position: absolute; + inset-inline-start: 21px; + top: -8px; + bottom: 0; + width: 3px; + background-color: var(--color-border-high); + opacity: 0.3; + border-radius: 0 0 var(--radius-field) var(--radius-field); + } +} + +.tierBadge { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 100%; + background-color: var(--color-bg-higher); + border: none; + padding: 0; + cursor: pointer; + flex-shrink: 0; +} + +.tierPopover { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-1); +} + +.tierPopoverName { + font-size: var(--font-sm); + font-weight: var(--weight-semi); + text-transform: capitalize; +} + +.memberGrid { + display: grid; + grid-template-areas: + "avatar name" + "tier meta"; + grid-template-columns: auto 1fr; + column-gap: var(--s-2); + row-gap: var(--s-1); + align-items: center; +} + +.memberLink { + grid-row: 1; + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + column-gap: var(--s-2); + align-items: center; +} + +.memberNameStack { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.memberInGameName { + font-size: var(--font-2xs); + color: var(--color-text-high); + font-weight: var(--weight-semi); +} + +.memberMenuTrigger { + background: none; + border: 0; + padding: 0; + color: inherit; + font: inherit; + text-align: inherit; + cursor: pointer; + width: 100%; +} + +.friendCodeHeader { + text-align: center; +} + +.memberMenuHeader { + display: flex; + flex-direction: column; + gap: var(--s-0-5); +} + +.memberMenuIgn { + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.memberMenuIgnLabel { + font-weight: var(--weight-bold); + text-transform: uppercase; + font-size: var(--font-3xs); +} + +.memberTier { + grid-area: tier; + justify-self: center; +} + +.memberMetaArea { + grid-area: meta; +} + +.memberMeta { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-2xs); +} + +.plusTier { + display: flex; + align-items: center; + gap: var(--s-0-5); + background-color: var(--color-bg-higher); + border-radius: var(--radius-full); + padding: var(--s-0-5) var(--s-1-5); + padding-inline-start: var(--s-1); + font-weight: var(--weight-semi); + color: var(--color-text); +} + +.subbedOutTrigger { + display: flex; + align-items: center; + gap: var(--s-1-5); +} + +.subbedOutIcon { + --subbed-out-icon-size: 24px; + display: flex; + align-items: center; + justify-content: center; + width: var(--subbed-out-icon-size); + height: var(--subbed-out-icon-size); + border-radius: 100%; + background-color: var(--color-bg-higher); +} + +.subbedOutPopover { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.subbedOutHeader { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + text-transform: uppercase; +} + +.rosterEditCount { + font-size: var(--font-xs); + color: var(--color-text-high); + margin-block-end: var(--s-2); + text-align: center; +} + +.rosterEditButtons { + display: flex; + gap: var(--s-2); + margin-block-start: var(--s-4); + justify-content: center; +} + +.teamOneDot { + border-radius: 100%; + background-color: var(--color-accent); + width: 8px; + height: 8px; +} + +.teamTwoDot { + border-radius: 100%; + background-color: var(--color-second); + width: 8px; + height: 8px; +} + +.teamAvatar { + border-radius: var(--radius-avatar); + width: 44px; + height: 44px; + flex-shrink: 0; + + &[data-side="alpha"] { + background-color: var(--color-accent); + } + + &[data-side="bravo"] { + background-color: var(--color-second); + } +} diff --git a/app/components/match-page/MatchRosterTab.tsx b/app/components/match-page/MatchRosterTab.tsx new file mode 100644 index 000000000..9ee552168 --- /dev/null +++ b/app/components/match-page/MatchRosterTab.tsx @@ -0,0 +1,530 @@ +import clsx from "clsx"; +import { Armchair, Edit, User } from "lucide-react"; +import { useState } from "react"; +import { Button as ReactAriaButton } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouMenu, + SendouMenuItem, + SendouMenuSection, +} from "~/components/elements/Menu"; +import { SendouPopover } from "~/components/elements/Popover"; +import { Image, TierImage } from "~/components/Image"; +import type { TierName } from "~/features/mmr/mmr-constants"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import invariant from "~/utils/invariant"; +import type { CommonUser } from "~/utils/kysely.server"; +import { + navIconUrl, + preferenceEmojiUrl, + tierImageUrl, + userPage, +} from "~/utils/urls"; +import { SendouTabPanel } from "../elements/Tabs"; +import styles from "./MatchRosterTab.module.css"; +import { TAB_KEYS } from "./MatchTabs"; +import { WeaponPool } from "./WeaponPool"; + +type RosterTabMember = CommonUser & { + tier?: { name: TierName; isPlus: boolean } | "CALCULATING"; + plusTier?: number | null; + weaponPool?: Array; + friendCode?: string | null; + privateNote?: { sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE" } | null; + inGameName?: string | null; +}; + +interface RosterTabTeam { + team?: { + id: number; + name: string; + url: string; + avatar?: string; + }; + defaultName?: string; + members: Array; + /** Sub user ids i.e. those who are not the current active roster */ + subbedOut?: Array; + tier?: { name: TierName; isPlus: boolean }; +} + +interface MatchRosterTabProps { + teams: [RosterTabTeam, RosterTabTeam]; + minMembersPerTeam: number; + canEditSubbedOut?: [boolean, boolean]; + defaultIsEditing?: [boolean, boolean]; + onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void; + isSubmitting?: boolean; +} + +export function MatchRosterTab({ + teams, + minMembersPerTeam, + canEditSubbedOut, + defaultIsEditing, + onSubbedOutChange, + isSubmitting, +}: MatchRosterTabProps) { + return ( + +
+ +
+ +
+ + ); +} + +function TeamRoster({ + team, + side, + canEditSubbedOut, + defaultIsEditing, + minMembersPerTeam, + onSubbedOutChange, + isSubmitting, +}: { + team: RosterTabTeam; + side: "alpha" | "bravo"; + canEditSubbedOut: boolean; + defaultIsEditing: boolean; + minMembersPerTeam: number; + onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void; + isSubmitting?: boolean; +}) { + const { t } = useTranslation(["common", "q"]); + const [isEditing, setIsEditing] = useState(defaultIsEditing); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + + const dotClassName = side === "alpha" ? styles.teamOneDot : styles.teamTwoDot; + const label = + side === "alpha" ? t("q:match.sides.alpha") : t("q:match.sides.bravo"); + + const subbedOutSet = new Set(team.subbedOut); + const activeMembers = team.members.filter( + (member) => !subbedOutSet.has(member.id), + ); + const subbedOutMembers = team.members.filter((member) => + subbedOutSet.has(member.id), + ); + + const showEditButton = canEditSubbedOut && team.team && !isEditing; + + return ( +
+ + {team.members.length > 0 ? ( +
    + {isEditing + ? team.members.map((member, index) => ( +
  • + +
  • + )) + : activeMembers.map((member) => ( +
  • + +
    + +
    +
    + +
    +
  • + ))} + {!isEditing && subbedOutMembers.length > 0 ? ( +
  • + +
  • + ) : null} +
+ ) : null} + {isEditing ? ( +
+
+ {selectedMemberIds.length}/{minMembersPerTeam} +
+
+ + {t("common:actions.submit")} + + {defaultIsEditing ? null : ( + + {t("common:actions.cancel")} + + )} +
+
+ ) : null} + {showEditButton ? ( + } + className="mt-4 mx-auto" + size="small" + onPress={() => { + setSelectedMemberIds(activeMembers.map((m) => m.id)); + setIsEditing(true); + }} + testId={`edit-active-roster-button-${side}`} + > + {t("common:actions.edit")} + + ) : null} +
+ ); + + function handleToggleMember(memberId: number) { + setSelectedMemberIds((prev) => + prev.includes(memberId) + ? prev.filter((id) => id !== memberId) + : [...prev, memberId], + ); + } + + function handleSubmit() { + if (!team.team || !onSubbedOutChange) return; + + const subbedOutIds = team.members + .filter((m) => !selectedMemberIds.includes(m.id)) + .map((m) => m.id); + onSubbedOutChange(team.team.id, subbedOutIds); + setIsEditing(false); + } + + function handleCancel() { + setSelectedMemberIds(activeMembers.map((m) => m.id)); + setIsEditing(false); + } +} + +function TeamHeader({ + team, + side, + label, + dotClassName, +}: { + team: RosterTabTeam; + side: "alpha" | "bravo"; + label: string; + dotClassName: string; +}) { + const tierText = team.tier + ? `${team.tier.name.toLowerCase()}${team.tier.isPlus ? "+" : ""}` + : undefined; + + if (team.team) { + return ( + + +
+

{team.team.name}

+
+
+ {label} + {tierText ? ( + <> + + {tierText} + + ) : null} +
+
+ + ); + } + + invariant(team.defaultName, "team or defaultName must be provided"); + + return ( +
+
+
+

{team.defaultName}

+
+
+ {label} + {tierText ? ( + <> + + {tierText} + + ) : null} +
+
+
+ ); +} + +function MemberTierPopover({ + tier, +}: { + tier?: { name: TierName; isPlus: boolean } | "CALCULATING"; +}) { + if (!tier) return null; + + return ( + + {tier === "CALCULATING" ? ( + + ) : ( + + )} + + } + > + + + ); +} + +function MemberTierPopoverContent({ + tier, +}: { + tier: { name: TierName; isPlus: boolean } | "CALCULATING"; +}) { + const { t } = useTranslation(["q"]); + + if (tier === "CALCULATING") { + return ( +
+ + + {t("q:looking.sp.calculating")} + +
+ ); + } + + return ( +
+ + + {tier.name.toLowerCase()} + {tier.isPlus ? "+" : ""} + +
+ ); +} + +function MemberMeta({ + plusTier, + weaponPool, +}: { + plusTier?: number | null; + weaponPool?: Array; +}) { + const hasPlusTier = typeof plusTier === "number"; + const hasWeapons = weaponPool && weaponPool.length > 0; + + if (!hasPlusTier && !hasWeapons) return null; + + return ( +
+ {hasPlusTier ? ( +
+ + {plusTier} +
+ ) : null} + {hasWeapons ? : null} +
+ ); +} + +function SubbedOutPopover({ members }: { members: Array }) { + const { t } = useTranslation(["q"]); + + return ( + +
+
+ +
+ +{members.length} +
+ + } + > +
+
{t("q:match.subbedOut")}
+ {members.map((member) => ( + + ))} +
+
+ ); +} + +function RosterMemberLink({ + member, + className, +}: { + member: RosterTabMember; + className?: string; +}) { + const { t } = useTranslation(["friends", "q", "user"]); + + const showNoteItem = member.privateNote !== undefined; + const hasContentBelowName = !!( + member.tier || + typeof member.plusTier === "number" || + (member.weaponPool && member.weaponPool.length > 0) + ); + const showIgnInMenu = hasContentBelowName && !!member.inGameName; + const showIgnUnderName = !hasContentBelowName && !!member.inGameName; + const useMenu = !!member.friendCode || showNoteItem || showIgnInMenu; + + const nameContent = ( +
+ {member.username} + {showIgnUnderName ? ( + {member.inGameName} + ) : null} +
+ ); + + if (!useMenu) { + return ( + + + {nameContent} + + ); + } + + const headerContent = + member.friendCode || showIgnInMenu ? ( +
+ {member.friendCode ? {`SW-${member.friendCode}`} : null} + {showIgnInMenu ? ( + + + {t("user:ign.short")}: + {" "} + {member.inGameName} + + ) : null} +
+ ) : undefined; + + return ( + + + {nameContent} + + } + > + + }> + {t("friends:friendsList.viewUserPage")} + + {showNoteItem ? ( + + ) : ( + + ) + } + > + {member.privateNote + ? t("q:looking.groups.editNote") + : t("q:looking.groups.addNote")} + + ) : null} + + + ); +} diff --git a/app/components/match-page/MatchTabs.module.css b/app/components/match-page/MatchTabs.module.css new file mode 100644 index 000000000..83e1fbf17 --- /dev/null +++ b/app/components/match-page/MatchTabs.module.css @@ -0,0 +1,7 @@ +.root { + & [class*="tabPanel"] { + background-color: var(--color-bg-high); + border-radius: 0 0 var(--radius-box) var(--radius-box); + padding: var(--s-6) var(--s-4); + } +} diff --git a/app/components/match-page/MatchTabs.tsx b/app/components/match-page/MatchTabs.tsx new file mode 100644 index 000000000..c05b0e97d --- /dev/null +++ b/app/components/match-page/MatchTabs.tsx @@ -0,0 +1,76 @@ +import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react"; +import type * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router"; +import invariant from "~/utils/invariant"; +import { SendouTab, SendouTabList, SendouTabs } from "../elements/Tabs"; +import styles from "./MatchTabs.module.css"; + +type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS]; +interface MatchTabsProps { + children: React.ReactNode; + tabs: Array; +} + +const TAB_KEY = "tab"; + +export const TAB_KEYS = { + ROSTERS: "rosters", + ACTION: "action", + JOIN: "join", + RESULT: "result", + ADMIN: "admin", +} as const; + +const TAB_ICONS: Record = { + rosters: , + action: , + join: , + result: , + admin: , +}; + +const TAB_TRANSLATION_KEYS = { + rosters: "q:match.tabs.rosters", + action: "q:match.tabs.action", + join: "common:actions.join", + result: "q:match.tabs.result", + admin: "common:pages.admin", +} as const; + +export function MatchTabs({ children, tabs }: MatchTabsProps) { + const { t } = useTranslation(["q", "common"]); + const [searchParams, setSearchParams] = useSearchParams(); + + const currentTab = + tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ?? tabs.at(0); + invariant(currentTab); + + return ( +
+ + setSearchParams( + { [TAB_KEY]: key as string }, + { + preventScrollReset: true, + unstable_defaultShouldRevalidate: false, + }, + ) + } + disappearing={false} + > + + {tabs.map((tab) => ( + + {t(TAB_TRANSLATION_KEYS[tab])} + + ))} + + + {children} + +
+ ); +} diff --git a/app/components/match-page/MatchTimeline.module.css b/app/components/match-page/MatchTimeline.module.css new file mode 100644 index 000000000..1fab63e50 --- /dev/null +++ b/app/components/match-page/MatchTimeline.module.css @@ -0,0 +1,289 @@ +.root { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + row-gap: var(--s-6); + column-gap: var(--s-4); + align-items: center; + width: 100%; +} + +.header { + display: contents; +} + +.headerTeam { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--s-1-5); +} + +.headerTeamBravo { + align-items: flex-start; +} + +.headerTeamName { + font-weight: var(--weight-bold); + font-size: var(--font-md); + text-box: trim-start cap alphabetic; + overflow-wrap: anywhere; +} + +.headerTeamNameLong { + font-size: var(--font-xs); +} + +.headerAvatars { + display: flex; + gap: var(--s-1); +} + +.headerScore { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.headerScoreValue { + font-size: var(--font-xl); + font-weight: var(--weight-extra); + line-height: 1; +} + +.headerScoreLive { + margin-top: var(--s-1); + font-size: var(--font-3xs); + font-weight: var(--weight-bold); + letter-spacing: 0.1em; + color: var(--color-error); +} + +.mapEvent { + display: contents; +} + +.mapSide { + display: grid; + grid-template-rows: auto 1fr auto; + align-self: stretch; + + &:first-child { + justify-self: end; + } + + &:last-child { + justify-self: start; + } +} + +.mapCenter { + display: grid; + grid-template-rows: auto 1fr auto; + justify-items: center; + gap: var(--s-1); +} + +.mapTimestamp { + font-size: var(--font-3xs); + color: var(--color-text-high); + font-weight: var(--weight-semi); +} + +.mapStageImage { + border-radius: var(--radius-box); +} + +.mapLabel { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-3xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.sideResult { + grid-row: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--s-2-5); +} + +.resultHeader { + display: flex; + align-items: baseline; + gap: var(--s-1); +} + +.resultLabel { + font-size: var(--font-xs); + font-weight: var(--weight-extra); + text-transform: uppercase; +} + +.resultPoints { + font-size: var(--font-3xs); + font-weight: var(--weight-semi); +} + +.eventRow { + display: contents; +} + +.eventAlpha { + justify-self: end; +} + +.subCenter { + display: flex; + justify-content: center; + align-items: center; +} + +.eventIcon { + color: var(--color-text-high); + background-color: var(--color-bg-higher); + border-radius: var(--radius-full); + padding: var(--s-1); +} + +.subDetail { + display: grid; + grid-template-columns: max-content 1fr; + align-items: center; + row-gap: var(--s-1); + column-gap: var(--s-3); +} + +.subLabelOut { + color: var(--color-error); + font-weight: var(--weight-bold); + font-size: var(--font-3xs); + text-transform: uppercase; +} + +.subLabelIn { + color: var(--color-success); + font-weight: var(--weight-bold); + font-size: var(--font-3xs); + text-transform: uppercase; +} + +.subPlayerName { + font-weight: var(--weight-semi); + font-size: var(--font-xs); +} + +.pickIcon { + color: var(--color-success); +} + +.banIcon { + color: var(--color-error); +} + +.pickBanGroup { + display: flex; + flex-wrap: wrap; + gap: var(--s-1-5); + justify-content: flex-end; + + &.pickBanGroupBravo { + justify-content: flex-start; + } +} + +.pickBanStageImage { + border-radius: var(--radius-box); +} + +.pickBanModeTile { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 32px; + background-color: var(--color-bg-higher); + border-radius: var(--radius-box); +} + +.spSection { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr auto 1fr; + column-gap: var(--s-8); + align-items: center; +} + +.spIcon { + display: flex; + justify-content: center; + align-items: center; +} + +.spColumn { + display: grid; + grid-template-columns: auto auto; + row-gap: var(--s-1-5); + column-gap: var(--s-2); + align-items: center; + justify-content: start; + + &:first-child { + justify-content: end; + } +} + +.spDetail { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + font-size: var(--font-xs); + align-items: center; +} + +.spDetailContent { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.spDeltaTrigger { + display: flex; + align-items: center; + gap: var(--s-2); + background: transparent; + border: none; + padding: var(--s-0-5) var(--s-1); + border-radius: var(--radius-field); + font-size: var(--font-xs); + font-weight: inherit; + color: inherit; + cursor: pointer; +} + +.spRawPopover { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + +.spCalculatingIcon { + font-size: 18px; +} + +.spTeamIcon { + width: 24px; + height: 24px; + border-radius: var(--radius-full); + background-color: var(--color-bg-higher); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-high); +} diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx new file mode 100644 index 000000000..6c9a52827 --- /dev/null +++ b/app/components/match-page/MatchTimeline.tsx @@ -0,0 +1,633 @@ +import clsx from "clsx"; +import { + ArrowRight, + MousePointerClick, + RefreshCcw, + TrendingUp, + Users, + X, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables"; +import { useHydrated } from "~/hooks/useHydrated"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; +import { shortStageName } from "~/modules/in-game-lists/stage-ids"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import type { CommonUser } from "~/utils/kysely.server"; +import { roundToNDecimalPlaces } from "~/utils/number"; +import { Avatar } from "../Avatar"; +import { SendouButton } from "../elements/Button"; +import { SendouPopover } from "../elements/Popover"; +import { ModeImage, StageImage } from "../Image"; +import styles from "./MatchTimeline.module.css"; +import { type InferredSubstitution, inferSubstitutions } from "./utils"; +import { WeaponPool } from "./WeaponPool"; + +const LONG_TEAM_NAME_THRESHOLD = 16; + +type MatchSide = "ALPHA" | "BRAVO"; + +export interface TimelineTeam { + name: string; + avatar?: string; +} + +export interface TimelineMap { + stageId: StageId; + mode: ModeShort; + timestamp: number; + winner: MatchSide; + rosters: { + alpha: CommonUser[]; + bravo: CommonUser[]; + }; + weapons?: { + alpha: Array; + bravo: Array; + }; + /** Optional point values [alpha, bravo] */ + points?: [number, number]; + /** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */ + pickedBy?: MatchSide; +} + +interface TimelineSpMember { + user: CommonUser; + skillDifference: UserSkillDifference; +} + +export interface TimelineSpChanges { + alpha: { + members: TimelineSpMember[]; + skillDifference?: GroupSkillDifference; + }; + bravo: { + members: TimelineSpMember[]; + skillDifference?: GroupSkillDifference; + }; +} + +export interface TimelinePickBanEvent { + /** "PICK" covers MODE_PICK (and the rare trailing-bucket map PICK); "BAN" covers map and mode bans. */ + kind: "PICK" | "BAN"; + /** Consecutive events of the same kind get merged into one row, regardless of side. */ + alphaEntries: Array<{ stageId?: StageId; mode?: ModeShort }>; + bravoEntries: Array<{ stageId?: StageId; mode?: ModeShort }>; +} + +export interface MatchTimelineProps { + teams: { alpha: TimelineTeam; bravo: TimelineTeam }; + score: { alpha: number; bravo: number }; + maps: TimelineMap[]; + spChanges?: TimelineSpChanges; + /** When true, render only the team + score header (no per-map rows or SP section). */ + compact?: boolean; + /** When true, the match is still in progress; renders a small LIVE label under the score. */ + isOngoing?: boolean; + /** + * Pick/ban events keyed by the slot they precede. Length = `maps.length + 1`. + * Bucket `i` renders above map row `i`; the trailing bucket renders after the + * last map row (covers events made after the latest result, or the + * pick/ban-only state with no maps reported yet). + */ + pickBanRowsBySlot?: TimelinePickBanEvent[][]; +} + +export function MatchTimeline({ + teams, + score, + maps, + spChanges, + compact = false, + isOngoing = false, + pickBanRowsBySlot, +}: MatchTimelineProps) { + return ( +
+ + {compact + ? null + : maps.map((map, i) => { + const previousMap = maps[i - 1]; + const substitutions = previousMap + ? inferSubstitutions(previousMap.rosters, map.rosters) + : []; + const pickBanRows = pickBanRowsBySlot?.[i] ?? []; + + return ( +
+ {pickBanRows.map((event, j) => ( + + ))} + {substitutions.map((sub, j) => ( + + ))} + +
+ ); + })} + {!compact && pickBanRowsBySlot + ? (pickBanRowsBySlot[maps.length] ?? []).map((event, j) => ( + + )) + : null} + {!compact && spChanges ? ( + + ) : null} +
+ ); +} + +function TimelineHeader({ + teams, + score, + maps, + isOngoing, +}: Pick) { + const { t } = useTranslation(["q"]); + const initialRosters = maps[0]?.rosters; + + return ( +
+
+
LONG_TEAM_NAME_THRESHOLD, + })} + > + {teams.alpha.name} +
+ {initialRosters ? ( +
+ {initialRosters.alpha.map((user) => ( + + ))} +
+ ) : null} +
+
+ + {score.alpha}-{score.bravo} + + {isOngoing ? ( + + {t("q:match.timeline.live")} + + ) : null} +
+
+
LONG_TEAM_NAME_THRESHOLD, + })} + > + {teams.bravo.name} +
+ {initialRosters ? ( +
+ {initialRosters.bravo.map((user) => ( + + ))} +
+ ) : null} +
+
+ ); +} + +function TimelineMapRow({ map }: { map: TimelineMap }) { + const { t } = useTranslation(["game-misc"]); + const isHydrated = useHydrated(); + const { formatTime } = useTimeFormat(); + + const alphaPoints = map.points?.[0]; + const bravoPoints = map.points?.[1]; + + return ( +
+
+ +
+
+ + +
+ + {shortStageName(t(`game-misc:STAGE_${map.stageId}`))} +
+
+
+ +
+
+ ); +} + +function SideResult({ + result, + points, + weapons, + isPicked, +}: { + result: "WIN" | "LOSS"; + points?: number; + weapons?: Array; + isPicked?: boolean; +}) { + const { t } = useTranslation(["q"]); + + return ( +
+
+ {isPicked ? ( + + ) : null} + + {result === "WIN" + ? t("q:match.timeline.win") + : t("q:match.timeline.loss")} + + {points === 100 ? ( + {t("q:match.action.ko")} + ) : null} +
+ {weapons ? : null} +
+ ); +} + +function TimelineEventRow({ + icon, + alphaContent, + bravoContent, +}: { + icon: React.ReactNode; + alphaContent: React.ReactNode; + bravoContent: React.ReactNode; +}) { + return ( +
+
{alphaContent}
+
{icon}
+
{bravoContent}
+
+ ); +} + +function TimelinePickBanRow({ event }: { event: TimelinePickBanEvent }) { + const isPick = event.kind === "PICK"; + const icon = isPick ? ( + + ) : ( + + ); + + return ( + 0 ? ( + + ) : null + } + bravoContent={ + event.bravoEntries.length > 0 ? ( + + ) : null + } + /> + ); +} + +function PickBanGroup({ + entries, + side, +}: { + entries: Array<{ stageId?: StageId; mode?: ModeShort }>; + side: MatchSide; +}) { + return ( +
+ {entries.map((entry, i) => ( + + ))} +
+ ); +} + +function PickBanEntry({ + entry, +}: { + entry: { stageId?: StageId; mode?: ModeShort }; +}) { + if (entry.stageId !== undefined) { + return ( + + ); + } + if (entry.mode !== undefined) { + return ( +
+ +
+ ); + } + return null; +} + +function TimelineSubstitutionRow({ + substitution, +}: { + substitution: InferredSubstitution; +}) { + return ( + } + alphaContent={ + substitution.side === "ALPHA" ? ( + + ) : null + } + bravoContent={ + substitution.side === "BRAVO" ? ( + + ) : null + } + /> + ); +} + +function SubstitutionDetail({ + substitution, +}: { + substitution: InferredSubstitution; +}) { + const { t } = useTranslation(["q"]); + + return ( +
+ {t("q:match.timeline.out")} +
+ + + {substitution.playerOut.username} + +
+ {t("q:match.timeline.in")} +
+ + + {substitution.playerIn.username} + +
+
+ ); +} + +function TimelineSpSection({ spChanges }: { spChanges: TimelineSpChanges }) { + const alphaMembersWithDiff = spChanges.alpha.members.filter( + (m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0, + ); + const bravoMembersWithDiff = spChanges.bravo.members.filter( + (m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0, + ); + + const maxMemberRows = Math.max( + alphaMembersWithDiff.length, + bravoMembersWithDiff.length, + ); + + if ( + maxMemberRows === 0 && + !spChanges.alpha.skillDifference && + !spChanges.bravo.skillDifference + ) { + return null; + } + + return ( +
+
+ {alphaMembersWithDiff.map((m) => ( + + ))} + {spChanges.alpha.skillDifference ? ( + + ) : null} +
+
+ +
+
+ {bravoMembersWithDiff.map((m) => ( + + ))} + {spChanges.bravo.skillDifference ? ( + + ) : null} +
+
+ ); +} + +function SpMemberDetail({ member }: { member: TimelineSpMember }) { + if (member.skillDifference.calculated) { + const { spDiff, oldSp, newSp } = member.skillDifference; + const isPositive = spDiff > 0; + const arrow = isPositive ? "▲" : "▼"; + + return ( +
+ + +
+ ); + } + + if ( + member.skillDifference.matchesCount === + member.skillDifference.matchesCountNeeded + ) { + return ( +
+ +
+ + + {member.skillDifference.newSp ? ( + <>{member.skillDifference.newSp}SP + ) : null} + +
+
+ ); + } + + return ( +
+ +
+ + + {member.skillDifference.matchesCount}/ + {member.skillDifference.matchesCountNeeded} + +
+
+ ); +} + +function SpTeamDetail({ + skillDifference, +}: { + skillDifference: GroupSkillDifference; +}) { + if (skillDifference.calculated) { + const { oldSp, newSp } = skillDifference; + const diff = roundToNDecimalPlaces(newSp - oldSp); + const isPositive = diff > 0; + const arrow = isPositive ? "▲" : "▼"; + + return ( +
+
+ +
+ +
+ ); + } + + if (skillDifference.newSp) { + return ( +
+
+ +
+
+ + {skillDifference.newSp}SP +
+
+ ); + } + + return ( +
+
+ +
+
+ + + {skillDifference.matchesCount}/{skillDifference.matchesCountNeeded} + +
+
+ ); +} + +function SpDeltaTrigger({ + arrow, + isPositive, + value, + oldSp, + newSp, +}: { + arrow: string; + isPositive: boolean; + value: number; + oldSp?: number; + newSp?: number; +}) { + const arrowClass = isPositive ? "text-success" : "text-warning"; + + if (oldSp === undefined || newSp === undefined) { + return ( +
+ {arrow} + {value}SP +
+ ); + } + + return ( + + {arrow} + {value}SP + + } + > +
+ {oldSp}SP + + {newSp}SP +
+
+ ); +} diff --git a/app/components/match-page/WeaponPool.module.css b/app/components/match-page/WeaponPool.module.css new file mode 100644 index 000000000..9b5e459ee --- /dev/null +++ b/app/components/match-page/WeaponPool.module.css @@ -0,0 +1,27 @@ +.weaponRow { + display: flex; + gap: var(--s-0-5); + background-color: var(--color-bg-higher); + border: none; + border-radius: var(--radius-full); + padding: var(--s-0-5) var(--s-1-5); + cursor: pointer; +} + +:global(html.light) .unknownWeapon { + filter: drop-shadow(0 0 1px var(--color-text)); +} + +.weaponPopover { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.weaponPopoverRow { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} diff --git a/app/components/match-page/WeaponPool.tsx b/app/components/match-page/WeaponPool.tsx new file mode 100644 index 000000000..640797d1d --- /dev/null +++ b/app/components/match-page/WeaponPool.tsx @@ -0,0 +1,54 @@ +import { Button } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { SendouPopover } from "../elements/Popover"; +import { Image, WeaponImage } from "../Image"; +import styles from "./WeaponPool.module.css"; + +export function WeaponPool({ + weapons, + size = 24, +}: { + weapons: Array; + size?: number; +}) { + const { t } = useTranslation(["weapons"]); + + return ( + + {weapons.map((weaponId, i) => + weaponId !== null ? ( + + ) : ( + ? + ), + )} + + } + > +
+ {weapons.map((weaponId, i) => + weaponId !== null ? ( +
+ + {t(`weapons:MAIN_${weaponId}` as any)} +
+ ) : null, + )} +
+
+ ); +} diff --git a/app/components/match-page/WeaponReporter.module.css b/app/components/match-page/WeaponReporter.module.css new file mode 100644 index 000000000..ea167ec24 --- /dev/null +++ b/app/components/match-page/WeaponReporter.module.css @@ -0,0 +1,108 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-4); + background-color: var(--color-bg-higher); + border-radius: 0 0 var(--radius-box) var(--radius-box); + padding: var(--s-4); + margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6)); + container-type: inline-size; +} + +.pastRow { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.mapRow { + display: flex; + align-items: center; + gap: var(--s-4); + + @container (max-width: 479px) { + flex-direction: column; + } +} + +.mapInfo { + display: flex; + flex-direction: column; + align-self: flex-end; + gap: var(--s-2); + + @container (max-width: 479px) { + align-self: center; + } +} + +.mapLabel { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-3xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.stageImage { + border-radius: var(--radius-box); +} + +.inputRow { + display: flex; + align-items: flex-end; + gap: var(--s-4); + + @container (max-width: 479px) { + width: 100%; + } +} + +.weaponSelectContainer { + min-width: 200px; + + & button { + border: var(--border-style-high); + } + + @container (max-width: 479px) { + flex: 1; + } +} + +.unreportedRow { + display: flex; + gap: var(--s-1); +} + +.rootCollapsed { + display: flex; + justify-content: center; + background-color: var(--color-bg-higher); + border-radius: 0 0 var(--radius-box) var(--radius-box); + padding: var(--s-2); + margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6)); +} + +.rootExpanded { + position: relative; +} + +.rootStandalone { + margin-block-start: calc(-1 * var(--s-6)); + min-height: 200px; + justify-content: center; +} + +.collapseButton { + position: absolute; + top: var(--s-2); + right: var(--s-3); + + & svg { + min-width: 22px; + max-width: 22px; + } +} diff --git a/app/components/match-page/WeaponReporter.tsx b/app/components/match-page/WeaponReporter.tsx new file mode 100644 index 000000000..8508caf1f --- /dev/null +++ b/app/components/match-page/WeaponReporter.tsx @@ -0,0 +1,170 @@ +import clsx from "clsx"; +import { ChevronUp, Crosshair } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import { useUser } from "~/features/auth/core/user"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import { abilityImageUrl, SETTINGS_PAGE } from "~/utils/urls"; +import { SendouButton } from "../elements/Button"; +import { Image, StageImage, WeaponImage } from "../Image"; +import { WeaponSelect } from "../WeaponSelect"; +import styles from "./WeaponReporter.module.css"; + +interface WeaponReporterMap { + stageId: StageId; + mode: ModeShort; +} + +export interface WeaponReporterProps { + maps: WeaponReporterMap[]; + pastReported: MainWeaponId[]; + nextMapIndex: number; + quickSelectWeaponIds?: MainWeaponId[]; + onSubmit: (weaponSplId: MainWeaponId) => void; + onUndo: () => void; + isSubmitting?: boolean; + standalone?: boolean; +} + +export function WeaponReporter({ + maps, + pastReported, + nextMapIndex, + quickSelectWeaponIds, + onSubmit, + onUndo, + isSubmitting, + standalone, +}: WeaponReporterProps) { + const { t } = useTranslation(["q", "game-misc", "common"]); + const user = useUser(); + const fetcher = useFetcher(); + const [isOpen, setIsOpen] = useState( + () => user?.preferences.weaponReportDefaultOpen ?? false, + ); + const [selectedWeapon, setSelectedWeapon] = useState( + null, + ); + + const inputTargetMap = nextMapIndex >= 0 ? maps[nextMapIndex] : undefined; + const unreportedCount = inputTargetMap + ? maps.length - pastReported.length - 1 + : maps.length - pastReported.length; + + const handleToggle = (newOpen: boolean) => { + setIsOpen(newOpen); + fetcher.submit( + { _action: "UPDATE_WEAPON_REPORT_DEFAULT_OPEN", newValue: newOpen }, + { method: "post", action: SETTINGS_PAGE, encType: "application/json" }, + ); + }; + + if (!isOpen && !standalone) { + return ( +
+ } + onPress={() => handleToggle(true)} + > + {t("q:match.actions.reportWeapons")} + +
+ ); + } + + return ( +
+ {standalone ? null : ( + } + onPress={() => handleToggle(false)} + className={styles.collapseButton} + aria-label={t("q:match.actions.reportWeapons")} + /> + )} + {inputTargetMap ? ( +
+ +
+
+ +
+ { + if (selectedWeapon === null) return; + onSubmit(selectedWeapon); + setSelectedWeapon(null); + }} + > + {t("common:actions.submit")} + +
+
+ ) : null} + {pastReported.length > 0 ? ( +
+ {pastReported.map((weaponId, i) => ( + + ))} + + {t("q:match.weapon.undoWeapon")} + +
+ ) : null} + {unreportedCount > 0 ? ( +
+ {Array.from({ length: unreportedCount }, (_, i) => ( + ? + ))} +
+ ) : null} +
+ ); +} + +function MapInfo({ map }: { map: WeaponReporterMap }) { + return ( +
+ +
+ ); +} diff --git a/app/components/match-page/useMatchWeaponReport.ts b/app/components/match-page/useMatchWeaponReport.ts new file mode 100644 index 000000000..3fa7a76a0 --- /dev/null +++ b/app/components/match-page/useMatchWeaponReport.ts @@ -0,0 +1,73 @@ +import { useFetcher } from "react-router"; +import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import type { WeaponReporterProps } from "./WeaponReporter"; + +/** + * Wires the `` component to the standard + * `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the + * locally persisted recently-reported weapons list. + * + * `maps` is the play order of maps the viewer can report a weapon for and + * `pastReported` is the weapons the viewer has already reported, paired + * with the `mapIndex` they were reported for. + */ +export function useMatchWeaponReport({ + maps, + pastReported, +}: { + maps: { stageId: StageId; mode: ModeShort }[]; + pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[]; +}): WeaponReporterProps { + const weaponFetcher = useFetcher(); + const { recentlyReportedWeapons, addRecentlyReportedWeapon } = + useRecentlyReportedWeapons(); + + const reportedMapIndexes = new Set(pastReported.map((w) => w.mapIndex)); + const nextMapIndex = (() => { + for (let i = 0; i < maps.length; i++) { + if (!reportedMapIndexes.has(i)) return i; + } + return -1; + })(); + const undoMapIndex = pastReported.reduce( + (max, w) => Math.max(max, w.mapIndex), + -1, + ); + + return { + maps, + pastReported: [...pastReported] + .sort((a, b) => a.mapIndex - b.mapIndex) + .map((w) => w.weaponSplId), + nextMapIndex, + quickSelectWeaponIds: recentlyReportedWeapons, + isSubmitting: weaponFetcher.state !== "idle", + onSubmit: (weaponSplId) => { + addRecentlyReportedWeapon(weaponSplId); + if (nextMapIndex < 0) return; + weaponFetcher.submit( + { + _action: "REPORT_WEAPON", + weaponSplId: String(weaponSplId), + mapIndex: String(nextMapIndex), + }, + { method: "post" }, + ); + }, + onUndo: () => { + if (undoMapIndex < 0) return; + weaponFetcher.submit( + { + _action: "UNDO_WEAPON_REPORT", + mapIndex: String(undoMapIndex), + }, + { method: "post" }, + ); + }, + }; +} diff --git a/app/components/match-page/utils.test.ts b/app/components/match-page/utils.test.ts new file mode 100644 index 000000000..20e8c2b51 --- /dev/null +++ b/app/components/match-page/utils.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, test } from "vitest"; +import type { CommonUser } from "~/utils/kysely.server"; +import { inferSubstitutions, resolveRoomPass } from "./utils"; + +function user(id: number): CommonUser { + return { + id, + username: `user${id}`, + discordId: `discord${id}`, + discordAvatar: null, + customUrl: null, + }; +} + +describe("inferSubstitutions", () => { + it("returns an empty array when rosters are unchanged", () => { + const rosters = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6), user(7), user(8)], + }; + + expect(inferSubstitutions(rosters, rosters)).toEqual([]); + }); + + it("detects a single substitution on alpha", () => { + const previous = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6), user(7), user(8)], + }; + const current = { + alpha: [user(1), user(2), user(3), user(9)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(4), playerIn: user(9) }, + ]); + }); + + it("detects substitutions on both sides in the same map transition", () => { + const previous = { + alpha: [user(1), user(2)], + bravo: [user(3), user(4)], + }; + const current = { + alpha: [user(1), user(10)], + bravo: [user(11), user(4)], + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(2), playerIn: user(10) }, + { side: "BRAVO", playerOut: user(3), playerIn: user(11) }, + ]); + }); + + it("pairs multiple substitutions on the same side by roster order", () => { + const previous = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6)], + }; + const current = { + alpha: [user(1), user(10), user(3), user(11)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(2), playerIn: user(10) }, + { side: "ALPHA", playerOut: user(4), playerIn: user(11) }, + ]); + }); + + it("ignores unpaired leavers when no new player joined", () => { + const previous = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6)], + }; + const current = { + alpha: [user(1), user(2), user(3)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([]); + }); + + it("ignores unpaired joiners when no player left", () => { + const previous = { + alpha: [user(1), user(2), user(3)], + bravo: [user(5), user(6)], + }; + const current = { + alpha: [user(1), user(2), user(3), user(9)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([]); + }); + + it("treats players switching sides as separate substitutions on each side", () => { + const previous = { + alpha: [user(1), user(2)], + bravo: [user(3), user(4)], + }; + const current = { + alpha: [user(3), user(4)], + bravo: [user(1), user(2)], + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(1), playerIn: user(3) }, + { side: "ALPHA", playerOut: user(2), playerIn: user(4) }, + { side: "BRAVO", playerOut: user(3), playerIn: user(1) }, + { side: "BRAVO", playerOut: user(4), playerIn: user(2) }, + ]); + }); +}); + +describe("resolveRoomPass", () => { + test("returns a 4-digit password", () => { + const pass = resolveRoomPass(12345); + + expect(pass).toMatch(/^\d{4}$/); + }); + + test("returns deterministic password for a given numeric seed", () => { + const pass1 = resolveRoomPass(12345); + const pass2 = resolveRoomPass(12345); + expect(pass1).toBe(pass2); + }); + + test("returns deterministic password for a given string seed", () => { + const pass1 = resolveRoomPass("test-seed"); + const pass2 = resolveRoomPass("test-seed"); + expect(pass1).toBe(pass2); + }); + + test("returns different passwords for different seeds", () => { + const pass1 = resolveRoomPass(1); + const pass2 = resolveRoomPass(2); + expect(pass1).not.toBe(pass2); + }); +}); diff --git a/app/components/match-page/utils.ts b/app/components/match-page/utils.ts new file mode 100644 index 000000000..22531e3d5 --- /dev/null +++ b/app/components/match-page/utils.ts @@ -0,0 +1,83 @@ +import * as R from "remeda"; +import type { CommonUser } from "~/utils/kysely.server"; +import { seededRandom } from "~/utils/random"; + +type MatchSide = "ALPHA" | "BRAVO"; + +type Rosters = { + alpha: CommonUser[]; + bravo: CommonUser[]; +}; + +export interface InferredSubstitution { + side: MatchSide; + playerOut: CommonUser; + playerIn: CommonUser; +} + +/** + * Compares the rosters of two consecutive maps and pairs up any + * players that dropped from a side with new players that joined the same side. + * The pairs are returned in roster order, so the first player out is paired with + * the first new player in. When the counts don't match, unpaired players are ignored. + */ +export function inferSubstitutions( + previousRosters: Rosters, + currentRosters: Rosters, +): InferredSubstitution[] { + const result: InferredSubstitution[] = []; + + for (const side of ["alpha", "bravo"] as const) { + const prevIds = new Set(previousRosters[side].map((u) => u.id)); + const currIds = new Set(currentRosters[side].map((u) => u.id)); + + const out = previousRosters[side].filter((u) => !currIds.has(u.id)); + const inn = currentRosters[side].filter((u) => !prevIds.has(u.id)); + + for (const [playerOut, playerIn] of R.zip(out, inn)) { + result.push({ + side: side === "alpha" ? "ALPHA" : "BRAVO", + playerOut, + playerIn, + }); + } + } + + return result; +} + +const NUM_MAP = { + "1": ["1", "2", "4"], + "2": ["2", "1", "3", "5"], + "3": ["3", "2", "6"], + "4": ["4", "1", "5", "7"], + "5": ["5", "2", "4", "6", "8"], + "6": ["6", "3", "5", "9"], + "7": ["7", "4", "8"], + "8": ["8", "7", "5", "9", "0"], + "9": ["9", "6", "8"], + "0": ["0", "8"], +}; + +/** + * Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed. + * + * Given the same seed, this function will always return the same password. + */ +export function resolveRoomPass(seed: number | string) { + let pass = "5"; + for (let i = 0; i < 3; i++) { + const { seededShuffle } = seededRandom(`${seed}-${i}`); + + const key = pass[i] as keyof typeof NUM_MAP; + const opts = NUM_MAP[key]; + const next = seededShuffle(opts)[0]; + pass += next; + } + + // prevent 5555 since many use it as a default pass + // making it a bit more common guess + if (pass === "5555") return "5800"; + + return pass; +} diff --git a/app/db/seed/constants.ts b/app/db/seed/constants.ts index 595c7dd4b..e64e40051 100644 --- a/app/db/seed/constants.ts +++ b/app/db/seed/constants.ts @@ -5,5 +5,8 @@ export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; // https://c export const NZAP_TEST_ID = 2; export const REGULAR_USER_TEST_ID = 2; export const ORG_ADMIN_TEST_ID = 3; +// Matches STAFF_IDS[0] (Panda) so the seeded user is recognized as STAFF. +export const STAFF_TEST_ID = 11329; +export const STAFF_TEST_DISCORD_ID = "138757634500067328"; export const AMOUNT_OF_CALENDAR_EVENTS = 200; diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index f093aa742..03d15d749 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -9,6 +9,7 @@ import * as AssociationRepository from "~/features/associations/AssociationRepos import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { tags } from "~/features/calendar/calendar-constants"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as LFGRepository from "~/features/lfg/LFGRepository.server"; import { TIMEZONES } from "~/features/lfg/lfg-constants"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; @@ -22,17 +23,8 @@ import { } from "~/features/plus-voting/core"; import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; -import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; -import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server"; -import { - summarizeMaps, - summarizePlayerResults, -} from "~/features/sendouq-match/core/summarizer.server"; -import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server"; -import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; -import * as SkillRepository from "~/features/sendouq-match/SkillRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; @@ -70,14 +62,19 @@ import { import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { randomTeamName } from "~/utils/team-name"; -import { mySlugify } from "~/utils/urls"; +import { mySlugify, navIconUrl, sendouQMatchPage } from "~/utils/urls"; import { getArtFilename, SEED_ART_URLS, SEED_TEAM_IMAGES, SEED_TOURNAMENT_IMAGES, } from "../../../scripts/seed-art-urls"; -import type { QWeaponPool, Tables, UserMapModePreferences } from "../tables"; +import type { + ParsedMemento, + QWeaponPool, + Tables, + UserMapModePreferences, +} from "../tables"; import { ADMIN_TEST_AVATAR, AMOUNT_OF_CALENDAR_EVENTS, @@ -85,6 +82,8 @@ import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID, ORG_ADMIN_TEST_ID, + STAFF_TEST_DISCORD_ID, + STAFF_TEST_ID, } from "./constants"; import placements from "./placements.json"; @@ -173,7 +172,9 @@ const basicSeeds = (variation?: SeedVariation | null) => [ makeAdminTournamentOrganizer, nzapUser, users, + staffUser, fixAdminId, + fixStaffUserId, makeArtists, adminUserWeaponPool, adminUserWidgets, @@ -769,6 +770,26 @@ function nzapUser() { }); } +function staffUser() { + return UserRepository.upsert({ + discordId: STAFF_TEST_DISCORD_ID, + discordName: "Panda", + twitch: null, + youtubeId: null, + discordAvatar: null, + discordUniqueName: null, + }); +} + +function fixStaffUserId() { + sql.prepare(`delete from user where id = ${STAFF_TEST_ID}`).run(); + sql + .prepare( + `update "User" set "id" = ${STAFF_TEST_ID} where "discordId" = '${STAFF_TEST_DISCORD_ID}'`, + ) + .run(); +} + async function users() { const usedNames = new Set(); for (let i = 0; i < 500; i++) { @@ -2577,12 +2598,21 @@ async function groups(variation?: SeedVariation | null) { .filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID); users.push(NZAP_TEST_ID); + let nzapGroupId = 0; + let sendouGroupId = 0; + const nzapGroupMemberIds: number[] = []; + const sendouGroupMemberIds: number[] = []; + for (let i = 0; i < 25; i++) { + const ownerId = users.pop()!; const group = await SQGroupRepository.createGroup({ status: "ACTIVE", - userId: users.pop()!, + userId: ownerId, }); + if (i === 0) nzapGroupMemberIds.push(ownerId); + if (i === 1) sendouGroupMemberIds.push(ownerId); + const amountOfAdditionalMembers = () => { if (SENDOU_IN_FULL_GROUP) { if (i === 0) return 3; @@ -2593,6 +2623,7 @@ async function groups(variation?: SeedVariation | null) { }; for (let j = 0; j < amountOfAdditionalMembers(); j++) { + const memberId = users.pop()!; sql .prepare( /* sql */ ` @@ -2602,15 +2633,100 @@ async function groups(variation?: SeedVariation | null) { ) .run({ groupId: group.id, - userId: users.pop()!, + userId: memberId, role: "REGULAR", }); + + if (i === 0) nzapGroupMemberIds.push(memberId); + if (i === 1) sendouGroupMemberIds.push(memberId); } + if (i === 0) nzapGroupId = group.id; + if (i === 1) sendouGroupId = group.id; + if (i === 0 && SENDOU_IN_FULL_GROUP) { users.push(ADMIN_ID); } } + + if (variation === "IN_SQ_MATCH") { + // Sendou's side tests the matchmade cascade vote flow, NZAP's side + // tests the trusted one-click flow. + sql + .prepare( + /* sql */ `update "Group" set "matchmade" = @matchmade where "id" = @id`, + ) + .run({ matchmade: 1, id: sendouGroupId }); + sql + .prepare( + /* sql */ `update "Group" set "matchmade" = @matchmade where "id" = @id`, + ) + .run({ matchmade: 0, id: nzapGroupId }); + + const mapList = randomMapList(sendouGroupId, nzapGroupId); + const memento = buildSeedMemento({ + mapList, + alphaGroupId: sendouGroupId, + bravoGroupId: nzapGroupId, + alphaMemberIds: sendouGroupMemberIds, + bravoMemberIds: nzapGroupMemberIds, + }); + + const createdMatch = await SQMatchRepository.create({ + alphaGroupId: sendouGroupId, + bravoGroupId: nzapGroupId, + mapList, + memento, + }); + + const guaranteedWeaponPoolUserIds = [ + sendouGroupMemberIds[1], + sendouGroupMemberIds[2], + nzapGroupMemberIds[1], + nzapGroupMemberIds[2], + ].filter((id): id is number => typeof id === "number"); + for (const userId of guaranteedWeaponPoolUserIds) { + const weapons: QWeaponPool[] = [ + { weaponSplId: 0, isFavorite: 1 }, + { weaponSplId: 2000, isFavorite: 0 }, + { weaponSplId: 4000, isFavorite: 0 }, + ]; + await db + .updateTable("User") + .set({ qWeaponPool: JSON.stringify(weapons) }) + .where("User.id", "=", userId) + .execute(); + } + + if (createdMatch.chatCode) { + await ChatSystemMessage.setMetadata({ + chatCode: createdMatch.chatCode, + header: `Match #${createdMatch.id}`, + subtitle: "SendouQ", + url: sendouQMatchPage(createdMatch.id), + imageUrl: `${navIconUrl("sendouq")}.avif`, + participantUserIds: [...sendouGroupMemberIds, ...nzapGroupMemberIds], + expiresAfter: { hours: 2 }, + }); + } + + const thirtyMinutesAgo = dateToDatabaseTimestamp( + sub(new Date(), { minutes: 30 }), + ); + sql + .prepare( + /* sql */ ` + insert into "RoomLink" ("userId", "url", "createdAt", "refreshedAt") + values (@userId, @url, @createdAt, @refreshedAt) + `, + ) + .run({ + userId: ADMIN_ID, + url: "https://example.com//private_battle/seed_room_123", + createdAt: thirtyMinutesAgo, + refreshedAt: thirtyMinutesAgo, + }); + } } async function teamMapPrefsGroups() { @@ -2702,6 +2818,108 @@ const randomMapList = ( return mapList; }; +function buildSeedMemento({ + mapList, + alphaGroupId, + bravoGroupId, + alphaMemberIds, + bravoMemberIds, +}: { + mapList: TournamentMapListMap[]; + alphaGroupId: number; + bravoGroupId: number; + alphaMemberIds: number[]; + bravoMemberIds: number[]; +}): ParsedMemento { + const userPools = new Map>>(); + + const addVote = (userId: number, mode: ModeShort, stageId: StageId) => { + let modes = userPools.get(userId); + if (!modes) { + modes = new Map(); + userPools.set(userId, modes); + } + let stages = modes.get(mode); + if (!stages) { + stages = new Set(); + modes.set(mode, stages); + } + stages.add(stageId); + }; + + for (const map of mapList) { + const candidates: number[] = + map.source === "BOTH" + ? [...alphaMemberIds, ...bravoMemberIds] + : map.source === alphaGroupId + ? alphaMemberIds + : map.source === bravoGroupId + ? bravoMemberIds + : []; + + if (candidates.length === 0) continue; + + const voterCount = faker.number.int({ min: 1, max: candidates.length }); + const voters = faker.helpers.arrayElements(candidates, voterCount); + + for (const voterId of voters) { + addVote(voterId, map.mode, map.stageId); + } + } + + const pools: ParsedMemento["pools"] = Array.from(userPools.entries()).map( + ([userId, modes]) => ({ + userId, + pool: Array.from(modes.entries()).map(([mode, stages]) => ({ + mode, + stages: Array.from(stages), + })), + }), + ); + + const tierNames = [ + "LEVIATHAN", + "DIAMOND", + "PLATINUM", + "GOLD", + "SILVER", + "BRONZE", + "IRON", + ] as const; + + const users: ParsedMemento["users"] = {}; + for (const userId of [...alphaMemberIds, ...bravoMemberIds]) { + const tierName = faker.helpers.arrayElement(tierNames); + users[userId] = { + skill: { + ordinal: faker.number.float({ min: 1000, max: 3000 }), + tier: { + name: tierName, + isPlus: faker.datatype.boolean(), + }, + approximate: false, + }, + }; + } + + const groups: ParsedMemento["groups"] = { + [alphaGroupId]: { + tier: { + name: faker.helpers.arrayElement(tierNames), + isPlus: faker.datatype.boolean(), + }, + }, + [bravoGroupId]: { + tier: { + name: faker.helpers.arrayElement(tierNames), + isPlus: faker.datatype.boolean(), + }, + }, + }; + + return { users, groups, pools }; +} + const MATCHES_COUNT = 500; const AMOUNT_OF_USERS_WITH_SKILLS = 100; @@ -2805,57 +3023,25 @@ async function playedMatches() { ["ALPHA", "BRAVO", "BRAVO", "ALPHA", "ALPHA", "ALPHA"], ["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"], ]) as ("ALPHA" | "BRAVO")[]; - const winner = winnersArrayToWinner(winners); - const finishedMatch = SendouQ.mapMatch( - (await SQMatchRepository.findById(match.id))!, - ); - const { newSkills, differences } = calculateMatchSkills({ - groupMatchId: match.id, - winner: winner === "ALPHA" ? groupAlphaMembers : groupBravoMembers, - loser: winner === "ALPHA" ? groupBravoMembers : groupAlphaMembers, - loserGroupId: winner === "ALPHA" ? groupBravo : groupAlpha, - winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo, - }); - - const members = [ - ...finishedMatch.groupAlpha.members.map((m) => ({ - ...m, - groupId: match.alphaGroupId, - })), - ...finishedMatch.groupBravo.members.map((m) => ({ - ...m, - groupId: match.bravoGroupId, - })), - ]; - await SQMatchRepository.updateScore({ - matchId: match.id, - reportedByUserId: - faker.number.float(1) > 0.5 - ? groupAlphaMembers[0] - : groupBravoMembers[0], - winners, - }); - await SkillRepository.createMatchSkills({ - skills: newSkills, - differences, - groupMatchId: match.id, - oldMatchMemento: { users: {}, groups: {}, pools: [] }, - }); - await SQGroupRepository.setAsInactive(groupAlpha); - await SQGroupRepository.setAsInactive(groupBravo); - await PlayerStatRepository.upsertMapResults( - summarizeMaps({ match: finishedMatch, members, winners }), - ); - await PlayerStatRepository.upsertPlayerResults( - summarizePlayerResults({ match: finishedMatch, members, winners }), - ); + const reporterUserId = + faker.number.float(1) > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0]; + for (const [mapIndex, winner] of winners.entries()) { + await SQMatchRepository.reportMapWinner({ + matchId: match.id, + winnerId: winner === "ALPHA" ? groupAlpha : groupBravo, + reportedByUserId: reporterUserId, + reportedCount: mapIndex, + isStaffReport: true, + }); + } // -> add weapons for 90% of matches if (faker.number.float(1) > 0.9) continue; + const finishedMatch = (await SQMatchRepository.findById(match.id))!; const users = [...groupAlphaMembers, ...groupBravoMembers]; const mapsWithUsers = users.flatMap((u) => - finishedMatch.mapList.map((m) => ({ map: m, user: u })), + finishedMatch.mapList.map((_, mapIndex) => ({ mapIndex, user: u })), ); await ReportedWeaponRepository.createMany( @@ -2873,7 +3059,8 @@ async function playedMatches() { }; return { - groupMatchMapId: mu.map.id, + groupMatchId: match.id, + mapIndex: mu.mapIndex, userId: mu.user, weaponSplId: weapon(), }; diff --git a/app/db/tables.ts b/app/db/tables.ts index fa125b60d..50676c8ca 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -237,6 +237,8 @@ export interface Group { id: GeneratedAlways; inviteCode: string; latestActionAt: Generated; + /** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */ + matchmade: Generated; status: "PREPARING" | "ACTIVE" | "INACTIVE"; teamId: number | null; } @@ -259,6 +261,8 @@ export type UserSkillDifference = | { calculated: true; spDiff: number; + oldSp?: number; + newSp?: number; } | CalculatingSkill; export type GroupSkillDifference = @@ -301,11 +305,21 @@ export interface GroupMatch { alphaGroupId: number; bravoGroupId: number; chatCode: string | null; + confirmedAt: number | null; + confirmedByUserId: number | null; createdAt: Generated; id: GeneratedAlways; memento: JSONColumnTypeNullable; - reportedAt: number | null; - reportedByUserId: number | null; + cancelRequestedByUserId: number | null; + cancelAcceptedByUserId: number | null; +} + +export interface GroupMatchContinueVote { + id: GeneratedAlways; + groupId: number; + userId: number; + isContinuing: DBBoolean; + votedAt: Generated; } export interface GroupMatchMap { @@ -313,6 +327,8 @@ export interface GroupMatchMap { index: number; matchId: number; mode: ModeShort; + reportedAt: number | null; + reportedByUserId: number | null; source: string; stageId: StageId; winnerGroupId: number | null; @@ -438,7 +454,9 @@ export interface PlusVotingResult { } export interface ReportedWeapon { - groupMatchMapId: number | null; + groupMatchId: number | null; + tournamentMatchId: number | null; + mapIndex: number; userId: number; weaponSplId: MainWeaponId; } @@ -986,10 +1004,20 @@ export interface UserPreferences { * "12h" = 12 hour format (e.g. 2:00 PM) * */ clockFormat?: "24h" | "12h" | "auto"; + /** + * What numeric date format the user prefers? + * + * "auto" = use the format the active language defaults to (default value) + * "MDY" = month/day/year (e.g. 4/27/2026) + * "DMY" = day/month/year (e.g. 27/04/2026) + * "YMD" = ISO year-month-day (e.g. 2026-04-27) + * */ + dateFormat?: "auto" | "MDY" | "DMY" | "YMD"; /** Is the new widget based user page enabled? (Supporter early preview) */ newProfileEnabled?: boolean; /** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */ spoilerFreeMode?: boolean; + weaponReportDefaultOpen?: boolean; } export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const; @@ -1050,6 +1078,8 @@ export interface User { qWeaponPool: JSONColumnTypeNullable; plusSkippedForSeasonNth: number | null; noScreen: Generated; + /** User doesn't have access to SplatNet 3 to join rooms made by others */ + noSplatnet: Generated; buildSorting: JSONColumnTypeNullable; preferences: JSONColumnTypeNullable; /** User creation date. Can be null because we did not always save this. */ @@ -1297,6 +1327,13 @@ export interface NotificationUserSubscription { subscription: JSONColumnType; } +export interface RoomLink { + userId: number; + url: string; + createdAt: Generated; + refreshedAt: Generated; +} + export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const; export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number]; @@ -1341,6 +1378,7 @@ export interface DB { Group: Group; GroupLike: GroupLike; GroupMatch: GroupMatch; + GroupMatchContinueVote: GroupMatchContinueVote; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; PrivateUserNote: PrivateUserNote; @@ -1353,6 +1391,7 @@ export interface DB { PlusTier: PlusTier; PlusVote: PlusVote; PlusVotingResult: PlusVotingResult; + RoomLink: RoomLink; ReportedWeapon: ReportedWeapon; Skill: Skill; SkillTeamUser: SkillTeamUser; diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 912966c21..cc56ce6b3 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -42,7 +42,6 @@ export default async function handleRequest( lng, // The locale we detected above ns, // The namespaces the routes about to render wants to use resources, - showSupportNotice: false, }); return new Promise((resolve, reject) => { diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 4f63512f4..32c9ca08c 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -39,6 +39,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { errorToast(`Migration failed. Reason: ${errorMessage}`); } + await refreshBannedCache(); + message = "Account migrated"; break; } catch (err) { diff --git a/app/features/api-private/constants.ts b/app/features/api-private/constants.ts index 80c54f859..d41db80f4 100644 --- a/app/features/api-private/constants.ts +++ b/app/features/api-private/constants.ts @@ -9,4 +9,5 @@ export const SEED_VARIATIONS = [ "TEAM_MAP_PREFS", "FINALIZED_BRACKET", "AB_RR", + "IN_SQ_MATCH", ] as const; diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index 2cfbad2f6..dae25c880 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import { db } from "~/db/sql"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; -import { resolveMapList } from "~/features/tournament-bracket/core/mapList.server"; import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import { resolveMapList } from "~/features/tournament-match/core/mapList.server"; import { i18next } from "~/modules/i18n/i18next.server"; import { logger } from "~/utils/logger"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; diff --git a/app/features/api-public/routes/tournament.$id.players.ts b/app/features/api-public/routes/tournament.$id.players.ts index f06f4c528..916065408 100644 --- a/app/features/api-public/routes/tournament.$id.players.ts +++ b/app/features/api-public/routes/tournament.$id.players.ts @@ -1,6 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { z } from "zod"; -import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server"; +import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import { parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import type { GetTournamentPlayersResponse } from "../schema"; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 3031d13e5..379e0867a 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -95,7 +95,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) { { if (fetcher.state !== "idle") return true; - return !img && !data.art; + return (!img || !smallImg) && !data.art; }; if (!isArtist) { @@ -121,7 +121,6 @@ function ImageUpload({ { const uploadedFile = e.target.files?.[0]; diff --git a/app/features/art/routes/art.tsx b/app/features/art/routes/art.tsx index 8372d066a..c0daf4c80 100644 --- a/app/features/art/routes/art.tsx +++ b/app/features/art/routes/art.tsx @@ -68,7 +68,7 @@ export const meta: MetaFunction = (args) => { }; export default function ArtPage() { - const { t } = useTranslation(["art", "common"]); + const { t } = useTranslation(["art", "common", "forms"]); const data = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); const switchId = React.useId(); @@ -100,7 +100,7 @@ export default function ArtPage() { id={switchId} />
>, "content"> & { slug: string; - dateString: string; } > = []; for (const file of files) { @@ -25,11 +24,6 @@ export async function mostRecentArticles(count: number) { articles.push({ date, slug: file.replace(".md", ""), - dateString: date.toLocaleDateString("en-US", { - day: "2-digit", - month: "long", - year: "numeric", - }), authors: normalizeAuthors(restParsed.author), title: restParsed.title, }); @@ -37,6 +31,5 @@ export async function mostRecentArticles(count: number) { return articles .sort((a, b) => b.date.getTime() - a.date.getTime()) - .slice(0, count) - .map(({ date: _date, ...rest }) => rest); + .slice(0, count); } diff --git a/app/features/articles/routes/a.$slug.tsx b/app/features/articles/routes/a.$slug.tsx index a8961c551..3ca9fa532 100644 --- a/app/features/articles/routes/a.$slug.tsx +++ b/app/features/articles/routes/a.$slug.tsx @@ -3,6 +3,7 @@ import type { MetaFunction } from "react-router"; import { Link, useLoaderData } from "react-router"; import { Main } from "~/components/Main"; import { Markdown } from "~/components/Markdown"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { @@ -52,12 +53,20 @@ export const meta: MetaFunction = (args) => { export default function ArticlePage() { const data = useLoaderData(); + const { formatDate } = useTimeFormat(); return (

{data.title}

- by + by •{" "} +
{contentWithoutLeadingTitle(data.content, data.title)} diff --git a/app/features/articles/routes/a.tsx b/app/features/articles/routes/a.tsx index 510f33c7f..04f0f1259 100644 --- a/app/features/articles/routes/a.tsx +++ b/app/features/articles/routes/a.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { Link, useLoaderData } from "react-router"; import { Main } from "~/components/Main"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; @@ -30,6 +31,7 @@ export const meta: MetaFunction = (args) => { export default function ArticlesMainPage() { const { t, i18n } = useTranslation(["common"]); + const { formatDate } = useTimeFormat(); const data = useLoaderData(); return ( @@ -46,7 +48,14 @@ export default function ArticlesMainPage() { style: "short", }).format(article.authors.map((a) => a.name)), })}{" "} - • + •{" "} +
))} diff --git a/app/features/badges/homemade.json b/app/features/badges/homemade.json index ab565d0ef..b28235205 100644 --- a/app/features/badges/homemade.json +++ b/app/features/badges/homemade.json @@ -767,6 +767,10 @@ "displayName": "Minus One Open", "authorDiscordId": "569271521776762896" }, + "moneycat": { + "displayName": "Cats Have 9 Lives", + "authorDiscordId": "781999824765452310" + }, "mrgrizz": { "displayName": "Salmon Workers", "authorDiscordId": "345996252032401408" @@ -1227,6 +1231,10 @@ "displayName": "Squidforce Cup", "authorDiscordId": "1320944066002681876" }, + "sssge": { + "displayName": "Solo Squid Showdown: Golden Edition", + "authorDiscordId": "824681527815438337" + }, "stellatedzone": { "displayName": "Stellated Zone", "authorDiscordId": "309327923129745409" @@ -1467,6 +1475,14 @@ "displayName": "Weapon Lockdown Special Edition", "authorDiscordId": "338806780446638082" }, + "wellstringcustom": { + "displayName": "Wellstring Propoganda: Low Cut", + "authorDiscordId": "338806780446638082" + }, + "wellstringregular": { + "displayName": "Wellstring Propoganda: Top Cut", + "authorDiscordId": "338806780446638082" + }, "whitecat": { "displayName": "White Cat Achievement", "authorDiscordId": "631246535560265749" @@ -1475,6 +1491,10 @@ "displayName": "Wi Wi Wi Cat", "authorDiscordId": "530722502603833346" }, + "wings": { + "displayName": "Wings Up!", + "authorDiscordId": "752582395076673577" + }, "wiper": { "displayName": "Squeaky Clean Scrap", "authorDiscordId": "528851510222782474" diff --git a/app/features/ban/loaders/suspended.server.ts b/app/features/ban/loaders/suspended.server.ts index 2dbed9b32..c8a878cbb 100644 --- a/app/features/ban/loaders/suspended.server.ts +++ b/app/features/ban/loaders/suspended.server.ts @@ -6,14 +6,19 @@ import { } from "~/features/auth/core/authenticator.server"; import { authSessionStorage } from "~/features/auth/core/session.server"; import type { Nullish } from "~/utils/types"; -import { userIsBanned } from "../core/banned.server"; +import { refreshBannedCache, userIsBanned } from "../core/banned.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await getUserIdEvenIfBanned(request); if (!userId || !userIsBanned(userId)) return redirect("/"); - const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId)!; + const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId); + + if (!bannedStatus) { + await refreshBannedCache(); + return redirect("/"); + } return { banned: bannedStatus.banned, diff --git a/app/features/ban/routes/suspended.tsx b/app/features/ban/routes/suspended.tsx index f0f1f7d6f..eda104133 100644 --- a/app/features/ban/routes/suspended.tsx +++ b/app/features/ban/routes/suspended.tsx @@ -25,7 +25,7 @@ export default function SuspendedPage() {
Ends:{" "} {formatDateTime(ends, { - month: "long", + month: "numeric", day: "numeric", year: "numeric", hour: "numeric", diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts index f8bd5bb39..e289be903 100644 --- a/app/features/build-analyzer/core/utils.ts +++ b/app/features/build-analyzer/core/utils.ts @@ -13,6 +13,7 @@ import { mainWeaponIds, nonBombSubWeaponIds, nonDamagingSpecialWeaponIds, + specialWeaponIds, subWeaponIds, weaponCategories, weaponIdToBaseWeaponId, @@ -216,7 +217,10 @@ export function validatedAnyWeaponFromSearchParams( if (rawWeapon?.startsWith("SPECIAL_")) { const id = Number(rawWeapon.replace("SPECIAL_", "")); - if (nonDamagingSpecialWeaponIds.includes(id)) { + if ( + !specialWeaponIds.includes(id as any) || + nonDamagingSpecialWeaponIds.includes(id) + ) { return DEFAULT_ANY_WEAPON; } diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 0441ef29a..3c3f7e96e 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -257,9 +257,9 @@ export async function ownerIdById(buildId: number) { .selectFrom("Build") .select("ownerId") .where("id", "=", buildId) - .executeTakeFirstOrThrow(); + .executeTakeFirst(); - return result.ownerId; + return result?.ownerId ?? null; } export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) { diff --git a/app/features/builds/builds-schemas.server.ts b/app/features/builds/builds-schemas.ts similarity index 100% rename from app/features/builds/builds-schemas.server.ts rename to app/features/builds/builds-schemas.ts diff --git a/app/features/builds/components/FilterSection.tsx b/app/features/builds/components/FilterSection.tsx index fa4d7bfda..281a27471 100644 --- a/app/features/builds/components/FilterSection.tsx +++ b/app/features/builds/components/FilterSection.tsx @@ -236,7 +236,7 @@ function DateFilter({ {patch} ( {formatDate(date, { day: "numeric", - month: "long", + month: "numeric", year: "numeric", })} ) diff --git a/app/features/builds/core/filter.server.ts b/app/features/builds/core/filter.server.ts index f28fd223f..fe48c1b5e 100644 --- a/app/features/builds/core/filter.server.ts +++ b/app/features/builds/core/filter.server.ts @@ -6,7 +6,7 @@ import type { } from "~/modules/in-game-lists/types"; import { databaseTimestampToDate } from "~/utils/dates"; import { assertUnreachable } from "~/utils/types"; -import type { BuildFiltersFromSearchParams } from "../builds-schemas.server"; +import type { BuildFiltersFromSearchParams } from "../builds-schemas"; import type { AbilityBuildFilter, DateBuildFilter, diff --git a/app/features/builds/loaders/builds.$slug.server.ts b/app/features/builds/loaders/builds.$slug.server.ts index 6a62f1687..655a41ab9 100644 --- a/app/features/builds/loaders/builds.$slug.server.ts +++ b/app/features/builds/loaders/builds.$slug.server.ts @@ -12,7 +12,7 @@ import { import { buildFiltersSearchParams, buildsLimitSearchParam, -} from "../builds-schemas.server"; +} from "../builds-schemas"; import { filterBuilds } from "../core/filter.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { diff --git a/app/features/builds/routes/builds.$slug.tsx b/app/features/builds/routes/builds.$slug.tsx index c43b42231..7c8c3902e 100644 --- a/app/features/builds/routes/builds.$slug.tsx +++ b/app/features/builds/routes/builds.$slug.tsx @@ -18,7 +18,6 @@ import { BuildCard } from "~/components/BuildCard"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { Main } from "~/components/Main"; -import { safeJSONParse } from "~/utils/json"; import { isRevalidation, metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; @@ -37,7 +36,10 @@ import { MAX_BUILD_FILTERS, PATCHES, } from "../builds-constants"; -import type { BuildFiltersFromSearchParams } from "../builds-schemas.server"; +import { + type BuildFiltersFromSearchParams, + buildFiltersSearchParams, +} from "../builds-schemas"; import type { AbilityBuildFilter, BuildFilter } from "../builds-types"; import { FilterSection } from "../components/FilterSection"; @@ -94,7 +96,10 @@ function parseFiltersFromSearchParams( const raw = searchParams.get(FILTER_SEARCH_PARAM_KEY); if (!raw) return []; - return safeJSONParse(raw, []); + const parsed = buildFiltersSearchParams.safeParse(raw); + if (!parsed.success || !parsed.data) return []; + + return parsed.data; } function extractMeaningfulFilters( diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index 1b2c84822..2fb09c967 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -28,12 +28,6 @@ export const tags = { QUALIFIER: { color: "#FFC0CB", }, - SZ: { - color: "#F44336", - }, - TW: { - color: "#D50000", - }, ONES: { color: "#FAEC25", }, @@ -97,12 +91,7 @@ export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number]; export const DAYS_SHOWN_AT_A_TIME = 4; /** Tags not shown on the tournament cards */ -export const EXCLUDED_TAGS: Array = [ - "CARDS", - "SR", - "SZ", - "TW", -]; +export const EXCLUDED_TAGS: Array = ["CARDS", "SR"]; export const CALENDAR_EVENT_RESULT = { MAX_PARTICIPANTS_COUNT: 1000, diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index 3db80b10f..931c3956b 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -63,8 +63,6 @@ const TAGS_TO_OMIT: CalendarEventTag[] = [ "SR", "S1", "S2", - "SZ", - "TW", "ONES", "DUOS", "TRIOS", diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index 263e67b87..5782ec19b 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -46,13 +46,13 @@ export interface ShowcaseCalendarEvent extends CommonEvent { hidden: boolean; isFinalized: boolean; minMembersPerTeam: number; - firstPlacer: { + firstPlacers: Array<{ teamName: string; logoUrl: string | null; members: (CommonUser & { country: Tables["User"]["country"] })[]; notShownMembersCount: number; div: string | null; - } | null; + }>; hasVods?: boolean; } diff --git a/app/features/calendar/components/Tags.module.css b/app/features/calendar/components/Tags.module.css index cf6925aba..8fb88b8f2 100644 --- a/app/features/calendar/components/Tags.module.css +++ b/app/features/calendar/components/Tags.module.css @@ -32,6 +32,10 @@ color: var(--color-text-inverse); } +:global(html.light) .tag { + color: var(--color-text); +} + .tagDeleteButton { margin-left: auto; @@ -41,3 +45,7 @@ margin-inline: var(--s-1) 0 !important; } } + +:global(html.light) .tagDeleteButton > svg { + color: var(--color-text); +} diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx index a90b5cd81..be39964c5 100644 --- a/app/features/calendar/components/TournamentCard.tsx +++ b/app/features/calendar/components/TournamentCard.tsx @@ -47,7 +47,7 @@ export function TournamentCard({ } return formatDateTimeSmartMinutes(date, { - month: "short", + month: "numeric", day: "numeric", hour: "numeric", weekday: "short", @@ -57,7 +57,8 @@ export function TournamentCard({ return (
0, })} data-testid="tournament-card" > @@ -117,15 +118,17 @@ export function TournamentCard({
) : null} - {isShowcase && tournament.firstPlacer ? ( + {isShowcase && tournament.firstPlacers.length > 0 ? ( ) : null}
- {isShowcase && tournament.firstPlacer && isCensored(tournament.id) ? ( + {isShowcase && + tournament.firstPlacers.length > 0 && + isCensored(tournament.id) ? ( reveal(tournament.id)} /> ) : null} {isShowcase && "hasVods" in tournament && tournament.hasVods ? ( @@ -157,20 +160,52 @@ export function TournamentCard({ } function TournamentFirstPlacers({ - firstPlacer, + firstPlacers, censored, }: { - firstPlacer: NonNullable; + firstPlacers: ShowcaseCalendarEvent["firstPlacers"]; + censored: boolean; +}) { + if (firstPlacers.length > 1) { + return ( +
+
+ {firstPlacers.map((placer) => ( + + ))} +
+
+ ); + } + + const placer = firstPlacers[0]; + + return ( +
+ +
+ ); +} + +function TournamentFirstPlacerWithMembers({ + placer, + censored, +}: { + placer: ShowcaseCalendarEvent["firstPlacers"][number]; censored: boolean; }) { const { t } = useTranslation(["front"]); return ( -
+ <>
- {!censored && firstPlacer.logoUrl ? ( + {!censored && placer.logoUrl ? ( - {censored ? "???" : firstPlacer.teamName} + {censored ? "???" : placer.teamName}
{t("front:showcase.card.winner")} - {firstPlacer.div ? ` (${firstPlacer.div})` : null} + {placer.div ? ` (${placer.div})` : null}
- {firstPlacer.members.map((member) => ( + {placer.members.map((member) => (
{!censored && member.country ? ( @@ -195,12 +230,34 @@ function TournamentFirstPlacers({ {censored ? "???" : member.username}{" "}
))} - {!censored && firstPlacer.notShownMembersCount > 0 ? ( + {!censored && placer.notShownMembersCount > 0 ? (
- +{firstPlacer.notShownMembersCount} + +{placer.notShownMembersCount}
) : null}
+ + ); +} + +function TournamentFirstPlacerTeamNameOnly({ + placer, + censored, +}: { + placer: ShowcaseCalendarEvent["firstPlacers"][number]; + censored: boolean; +}) { + const { t } = useTranslation(["front"]); + + return ( +
+ + {censored ? "???" : placer.teamName} + +
+ {t("front:showcase.card.winner")} + {placer.div ? ` (${placer.div})` : null} +
); } diff --git a/app/features/calendar/routes/calendar.$id.tsx b/app/features/calendar/routes/calendar.$id.tsx index 1ed6d362a..dbd6a1a13 100644 --- a/app/features/calendar/routes/calendar.$id.tsx +++ b/app/features/calendar/routes/calendar.$id.tsx @@ -105,7 +105,7 @@ export default function CalendarEventPage() { hour: "numeric", minute: "numeric", day: "numeric", - month: "long", + month: "numeric", weekday: "long", year: "numeric", }) diff --git a/app/features/calendar/routes/calendar.module.css b/app/features/calendar/routes/calendar.module.css index 5c531d916..45cd5539c 100644 --- a/app/features/calendar/routes/calendar.module.css +++ b/app/features/calendar/routes/calendar.module.css @@ -8,6 +8,7 @@ .navigateButtonsContainer { display: flex; + flex-wrap: wrap; gap: var(--s-4); width: 100%; } @@ -31,6 +32,9 @@ &:not(.navigateArrowButton) { justify-content: center; + flex-basis: 100%; + background-color: var(--color-bg); + min-height: 30px; } & svg { @@ -50,7 +54,26 @@ } } +.navigateArrowButtonRange { + font-variant-numeric: tabular-nums; + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +@container (width >= 640px) { + .navigateArrowButton { + min-width: 7.25rem; + flex-basis: auto; + } + + .navigateButton:not(.navigateArrowButton) { + flex-basis: auto; + } +} + .navigateArrowButton { + gap: var(--s-1); + & svg { margin-inline-start: -5px; } diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 4bcd7ebd2..6c3a00768 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -598,7 +598,7 @@ function TagsAdder() { const tagsForSelect = CALENDAR_EVENT.TAGS.filter( (tag) => !tags.includes(tag), - ).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration? + ); return (
diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index e811f0740..885475495 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -144,17 +144,16 @@ function NavigateButton({ daysInterval: ReturnType["shown"]; filters?: CalendarLoaderData["filters"]; }) { - const { formatDate } = useTimeFormat(); + const { formatDateRange } = useTimeFormat(); const lowestDate = daysInterval[0]; const highestDate = daysInterval[daysInterval.length - 1]; - const dateToString = ( - day: ReturnType["shown"][number], - ) => - formatDate(new Date(new Date().getFullYear(), day.month, day.day), { - day: "numeric", - month: "short", - }); + const year = new Date().getFullYear(); + const rangeString = formatDateRange( + new Date(year, lowestDate.month, lowestDate.day), + new Date(year, highestDate.month, highestDate.day), + { day: "numeric", month: "numeric" }, + ); return (
{children}
-
- {dateToString(lowestDate)} - {dateToString(highestDate)} -
+
{rangeString}
); @@ -264,7 +261,7 @@ function DayHeader(props: { date: number; month: number; year: number }) { > {formatDate(date, { day: "numeric", - month: "long", + month: "numeric", })}
{formatDate(date, { diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index a4a52abdc..3e76874ef 100644 --- a/app/features/chat/ChatProvider.tsx +++ b/app/features/chat/ChatProvider.tsx @@ -276,7 +276,7 @@ function ChatProviderInner({ "system:", isSystemMessage, ); - if (isSystemMessage) { + if (isSystemMessage || messageArr[0].revalidateOnly) { revalidate(); } @@ -684,7 +684,7 @@ function useChatRouteSync({ ]); } -function useCurrentRouteChatCode(): string | string[] | null { +export function useCurrentRouteChatCode(): string | string[] | null { const matches = useMatches(); for (const match of matches) { diff --git a/app/features/chat/ChatSystemMessage.server.ts b/app/features/chat/ChatSystemMessage.server.ts index 18cd53bf1..69da91c06 100644 --- a/app/features/chat/ChatSystemMessage.server.ts +++ b/app/features/chat/ChatSystemMessage.server.ts @@ -141,6 +141,10 @@ export async function setMetadata(args: SetMetadataArgs) { args.participantUserIds, ); + logger.debug( + `Setting chat room metadata for ${args.chatCode} (participants: ${participantsKey})`, + ); + return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, { method: "POST", body: JSON.stringify({ diff --git a/app/features/chat/RoomLinkRepository.server.ts b/app/features/chat/RoomLinkRepository.server.ts new file mode 100644 index 000000000..68f092930 --- /dev/null +++ b/app/features/chat/RoomLinkRepository.server.ts @@ -0,0 +1,58 @@ +import { sub } from "date-fns"; +import { db } from "~/db/sql"; +import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; + +export function upsert(args: { userId: number; url: string }) { + return db + .insertInto("RoomLink") + .values({ + userId: args.userId, + url: args.url, + }) + .onConflict((oc) => + oc.column("userId").doUpdateSet({ + url: args.url, + createdAt: databaseTimestampNow(), + refreshedAt: databaseTimestampNow(), + }), + ) + .execute(); +} + +export function findByUserIds(userIds: number[], maxAgeHours: number) { + return db + .selectFrom("RoomLink") + .select([ + "RoomLink.userId", + "RoomLink.url", + "RoomLink.createdAt", + "RoomLink.refreshedAt", + ]) + .where("RoomLink.userId", "in", userIds) + .where( + "RoomLink.createdAt", + ">=", + dateToDatabaseTimestamp(sub(new Date(), { hours: maxAgeHours })), + ) + .orderBy("RoomLink.refreshedAt", "asc") + .execute(); +} + +export function refreshTimestamp(userId: number) { + return db + .updateTable("RoomLink") + .set({ refreshedAt: databaseTimestampNow() }) + .where("userId", "=", userId) + .execute(); +} + +export function deleteOld() { + return db + .deleteFrom("RoomLink") + .where( + "refreshedAt", + "<", + dateToDatabaseTimestamp(sub(new Date(), { hours: 2 })), + ) + .executeTakeFirst(); +} diff --git a/app/features/chat/chat-constants.test.ts b/app/features/chat/chat-constants.test.ts new file mode 100644 index 000000000..7c5b87349 --- /dev/null +++ b/app/features/chat/chat-constants.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "vitest"; +import { + extractRoomLink, + findRoomLinks, + isSplatnetRoomUrl, +} from "./chat-constants"; + +describe("isSplatnetRoomUrl", () => { + test("accepts canonical SplatNet share path", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com/av5ja-lp1/abc123")).toBe( + true, + ); + }); + + test("accepts a simple alphanumeric path", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com/abcdef")).toBe(true); + }); + + test("rejects http (non-https)", () => { + expect(isSplatnetRoomUrl("http://s.nintendo.com/abc")).toBe(false); + }); + + test("rejects unescaped-dot lookalike host (sanintendoacom.evil.tld)", () => { + expect(isSplatnetRoomUrl("https://sanintendoacom.evil.tld/lobby")).toBe( + false, + ); + }); + + test("rejects dash variant host (s-nintendo-com.evil.tld)", () => { + expect(isSplatnetRoomUrl("https://s-nintendo-com.evil.tld/lobby")).toBe( + false, + ); + }); + + test("rejects userinfo in URL (s.nintendo.com@evil.com)", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com@evil.com/abc")).toBe( + false, + ); + }); + + test("rejects custom port", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com:8080/abc")).toBe(false); + }); + + test("rejects query string", () => { + expect( + isSplatnetRoomUrl("https://s.nintendo.com/abc?redirect=evil.com"), + ).toBe(false); + }); + + test("rejects fragment", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com/abc#@evil.com")).toBe( + false, + ); + }); + + test("rejects trailing dot in hostname", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com./abc")).toBe(false); + }); + + test("rejects empty path", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com/")).toBe(false); + }); + + test("rejects path with disallowed characters", () => { + expect(isSplatnetRoomUrl("https://s.nintendo.com/abc!def")).toBe(false); + }); + + test("rejects malformed URL", () => { + expect(isSplatnetRoomUrl("not a url")).toBe(false); + }); +}); + +describe("findRoomLinks", () => { + test("returns empty array when no links", () => { + expect(findRoomLinks("just chatting here")).toEqual([]); + }); + + test("finds a valid link with its index", () => { + const text = "join: https://s.nintendo.com/abc123 thanks"; + expect(findRoomLinks(text)).toEqual([ + { url: "https://s.nintendo.com/abc123", index: 6 }, + ]); + }); + + test("ignores spoofed lookalike hosts even when surrounding text matches the candidate regex", () => { + const text = "join here https://sanintendoacom.evil.tld/lobby right now"; + expect(findRoomLinks(text)).toEqual([]); + }); + + test("ignores links with query strings", () => { + const text = + "https://s.nintendo.com/abc?redirect=https://evil.com legitimate?"; + expect(findRoomLinks(text)).toEqual([]); + }); + + test("returns multiple valid links", () => { + const text = + "https://s.nintendo.com/aaa and also https://s.nintendo.com/bbb"; + expect(findRoomLinks(text)).toEqual([ + { url: "https://s.nintendo.com/aaa", index: 0 }, + { url: "https://s.nintendo.com/bbb", index: 36 }, + ]); + }); +}); + +describe("extractRoomLink", () => { + test("returns first valid link", () => { + expect(extractRoomLink("hi https://s.nintendo.com/abc see you")).toBe( + "https://s.nintendo.com/abc", + ); + }); + + test("returns null when no valid link present", () => { + expect(extractRoomLink("https://sanintendoacom.evil.tld/abc")).toBeNull(); + }); +}); diff --git a/app/features/chat/chat-constants.ts b/app/features/chat/chat-constants.ts index 8e0f93316..93b0974d1 100644 --- a/app/features/chat/chat-constants.ts +++ b/app/features/chat/chat-constants.ts @@ -1 +1,44 @@ export const MESSAGE_MAX_LENGTH = 200; + +const SPLATNET_ROOM_HOST = "s.nintendo.com"; +const SPLATNET_ROOM_PATH_PATTERN = /^\/[A-Za-z0-9/_-]+$/; +const SPLATNET_ROOM_CANDIDATE_PATTERN = /https:\/\/s\.nintendo\.com\/\S+/g; + +export function isSplatnetRoomUrl(url: string): boolean { + if (!URL.canParse(url)) return false; + const parsed = new URL(url); + return ( + parsed.protocol === "https:" && + parsed.hostname === SPLATNET_ROOM_HOST && + parsed.username === "" && + parsed.password === "" && + parsed.port === "" && + parsed.search === "" && + parsed.hash === "" && + SPLATNET_ROOM_PATH_PATTERN.test(parsed.pathname) + ); +} + +export function findRoomLinks( + text: string, +): Array<{ url: string; index: number }> { + const results: Array<{ url: string; index: number }> = []; + for (const match of text.matchAll(SPLATNET_ROOM_CANDIDATE_PATTERN)) { + if (isSplatnetRoomUrl(match[0])) { + results.push({ url: match[0], index: match.index }); + } + } + return results; +} + +export function extractRoomLink(text: string): string | null { + return findRoomLinks(text)[0]?.url ?? null; +} + +const MATCH_ROOM_URL_PATTERN = + /^\/q\/match\/\d+$|^\/to\/\d+\/matches\/\d+$|^\/scrims\/\d+$/; + +export function isMatchRoomUrl(url: string) { + const pathname = URL.canParse(url) ? new URL(url).pathname : url; + return MATCH_ROOM_URL_PATTERN.test(pathname); +} diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 3796891f1..6a535db07 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -9,6 +9,7 @@ export type SystemMessageType = | "SCORE_CONFIRMED" | "CANCEL_REPORTED" | "CANCEL_CONFIRMED" + | "CANCEL_REFUSED" | "TOURNAMENT_UPDATED" | "TOURNAMENT_MATCH_UPDATED"; diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index d0c0ab2a6..b562abf84 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -107,6 +107,12 @@ opacity: 0.7; } +.roomLink { + color: var(--color-text-accent); + text-decoration: underline; + word-break: break-all; +} + .roomButton { border: 0; border-bottom: var(--border-style); diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index d936431f9..431faf511 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -8,7 +8,7 @@ import { Avatar } from "../../../components/Avatar"; import { SendouButton } from "../../../components/elements/Button"; import { SubmitButton } from "../../../components/SubmitButton"; import { useTimeFormat } from "../../../hooks/useTimeFormat"; -import { MESSAGE_MAX_LENGTH } from "../chat-constants"; +import { findRoomLinks, MESSAGE_MAX_LENGTH } from "../chat-constants"; import { useChatAutoScroll } from "../chat-hooks"; import type { ChatMessage, ChatProps, ChatUser } from "../chat-types"; import styles from "./Chat.module.css"; @@ -95,6 +95,9 @@ export function Chat({ case "CANCEL_CONFIRMED": { return t("common:chat.systemMsg.cancelConfirmed", { name: name() }); } + case "CANCEL_REFUSED": { + return t("common:chat.systemMsg.cancelRefused", { name: name() }); + } case "USER_LEFT": { return t("common:chat.systemMsg.userLeft", { name: name() }); } @@ -268,7 +271,9 @@ function Message({ [styles.messageContentsPending]: message.pending, })} > - {message.contents} + {message.contents ? ( + + ) : null}
@@ -301,6 +306,39 @@ function SystemMessage({ ); } +function MessageContents({ text }: { text: string }) { + const matches = findRoomLinks(text); + + if (matches.length === 0) return <>{text}; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + for (const [i, match] of matches.entries()) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + parts.push( + + {match.url} + , + ); + lastIndex = match.index + match.url.length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; +} + function MessageTimestamp({ timestamp }: { timestamp: number }) { const { formatDateTime, formatTime } = useTimeFormat(); const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp); diff --git a/app/features/chat/room-link-utils.ts b/app/features/chat/room-link-utils.ts new file mode 100644 index 000000000..a494547c9 --- /dev/null +++ b/app/features/chat/room-link-utils.ts @@ -0,0 +1,81 @@ +import { differenceInMinutes } from "date-fns"; +import { useFetcher } from "react-router"; +import { databaseTimestampToDate } from "~/utils/dates"; + +interface RoomLink { + userId: number; + url: string; + refreshedAt: number; +} + +interface ResolveActiveRoomLinkArgs { + /** Room links for all match participants, sorted by `refreshedAt` ascending. */ + roomLinks: ReadonlyArray; + /** Database timestamp before which a link is considered stale (e.g. match start time). */ + freshnessCutoff: number; + /** Viewer user id, used as fallback to surface the viewer's own stale link. */ + viewerUserId?: number; + /** Members shown to resolve `hostedBy`. */ + members: ReadonlyArray<{ id: number; username: string }>; +} + +interface ActiveRoomLink { + joinLink?: string; + hostedBy?: string; + isStale?: boolean; + staleMinutesAgo: number; + refreshedAt?: Date; +} + +/** + * Selects the room link to display for a match. Prefers the oldest link refreshed + * after the freshness cutoff (the host's confirmed room). Falls back to the + * viewer's own stale link so they can refresh it themselves. + */ +export function resolveActiveRoomLink({ + roomLinks, + freshnessCutoff, + viewerUserId, + members, +}: ResolveActiveRoomLinkArgs): ActiveRoomLink { + const validRoomLink = roomLinks.find( + (rl) => rl.refreshedAt >= freshnessCutoff, + ); + const ownStaleRoomLink = validRoomLink + ? undefined + : roomLinks.find((rl) => rl.userId === viewerUserId); + + const activeRoomLink = validRoomLink ?? ownStaleRoomLink; + + return { + joinLink: activeRoomLink?.url, + hostedBy: activeRoomLink + ? members.find((m) => m.id === activeRoomLink.userId)?.username + : undefined, + isStale: activeRoomLink ? !validRoomLink : undefined, + staleMinutesAgo: ownStaleRoomLink + ? differenceInMinutes( + new Date(), + databaseTimestampToDate(ownStaleRoomLink.refreshedAt), + ) + : 0, + refreshedAt: validRoomLink + ? databaseTimestampToDate(validRoomLink.refreshedAt) + : undefined, + }; +} + +/** Confirms the viewer's room link by refreshing its timestamp via the central `/room` action. */ +export function useConfirmRoom() { + const fetcher = useFetcher(); + + return { + onConfirmRoom: () => { + fetcher.submit( + { _action: "CONFIRM" }, + { method: "post", action: "/room", encType: "application/json" }, + ); + }, + isConfirming: fetcher.state !== "idle", + }; +} diff --git a/app/features/chat/routes/room.ts b/app/features/chat/routes/room.ts new file mode 100644 index 000000000..8f8ca195d --- /dev/null +++ b/app/features/chat/routes/room.ts @@ -0,0 +1,37 @@ +import type { ActionFunctionArgs } from "react-router"; +import { z } from "zod"; +import { requireUser } from "~/features/auth/core/user.server"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { isSplatnetRoomUrl } from "../chat-constants"; +import * as RoomLinkRepository from "../RoomLinkRepository.server"; + +const roomLinkSchema = z.discriminatedUnion("_action", [ + z.object({ + _action: z.literal("UPSERT"), + url: z.string().refine(isSplatnetRoomUrl, "Not a SplatNet room URL"), + }), + z.object({ + _action: z.literal("CONFIRM"), + }), +]); + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = requireUser(); + const data = await parseRequestPayload({ + request, + schema: roomLinkSchema, + }); + + switch (data._action) { + case "UPSERT": { + await RoomLinkRepository.upsert({ userId: user.id, url: data.url }); + break; + } + case "CONFIRM": { + await RoomLinkRepository.refreshTimestamp(user.id); + break; + } + } + + return null; +}; diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 7e3511292..218cd888b 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -49,6 +49,21 @@ export function getLiveTournamentStreams(): SidebarStream[] { return streams; } +/** Lowercased Twitch usernames of all members and casters streaming a currently live tournament. */ +export function getLiveTournamentStreamerTwitchNames(): string[] { + const names: string[] = []; + + for (const tournament of RunningTournaments.all) { + if (tournament.isLeagueDivision) continue; + + for (const stream of tournament.streams) { + names.push(stream.twitchUserName.toLowerCase()); + } + } + + return names; +} + function deriveCurrentRound(tournament: Tournament): string { for (const bracket of tournament.brackets.toReversed()) { if (bracket.preview) continue; diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx index 5de8280eb..1402cb141 100644 --- a/app/features/friends/components/FriendMenu.tsx +++ b/app/features/friends/components/FriendMenu.tsx @@ -11,6 +11,7 @@ import { } from "~/components/elements/Menu"; import { ListButton } from "~/components/SideNav"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import { databaseTimestampToDate } from "~/utils/dates"; import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls"; @@ -38,12 +39,17 @@ export function FriendMenu({ onNavigate?: () => void; }) { const { t } = useTranslation(["common", "friends"]); + const { formatDate } = useTimeFormat(); const fetcher = useFetcher(); const [confirmOpen, setConfirmOpen] = React.useState(false); const friendSinceText = friendshipCreatedAt ? t("friends:friendsList.friendSince", { - date: databaseTimestampToDate(friendshipCreatedAt).toLocaleDateString(), + date: formatDate(databaseTimestampToDate(friendshipCreatedAt), { + day: "numeric", + month: "numeric", + year: "numeric", + }), }) : null; diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index 2064eef28..792652114 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -1,10 +1,12 @@ import cachified from "@epic-web/cachified"; +import * as R from "remeda"; import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { getBracketProgressionLabel, tournamentIsRanked, } from "~/features/tournament/tournament-utils"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { @@ -186,16 +188,21 @@ function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) { const threeDaysAgo = databaseTimestampThreeDaysAgo(); const nonResults = tournaments.filter( (tournament) => - !tournament.firstPlacer && + tournament.firstPlacers.length === 0 && !tournament.isFinalized && tournament.startTime > threeDaysAgo, ); const rankedResults = tournaments - .filter((tournament) => tournament.firstPlacer && tournament.isRanked) + .filter( + (tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked, + ) .sort((a, b) => showcaseScore(b) - showcaseScore(a)); const nonRankedResults = tournaments - .filter((tournament) => tournament.firstPlacer && !tournament.isRanked) + .filter( + (tournament) => + tournament.firstPlacers.length > 0 && !tournament.isRanked, + ) .sort((a, b) => showcaseScore(b) - showcaseScore(a)); const rankedResultsToKeep = rankedResults.slice(0, 4); @@ -283,7 +290,7 @@ const MEMBERS_TO_SHOW = 5; function mapTournamentFromDB( tournament: TournamentRepository.ForShowcase, ): ShowcaseCalendarEvent { - const highestDivWinners = resolveHighestDivisionWinners(tournament); + const firstPlacers = resolveFirstPlacers(tournament); const tentativeTier = tournament.tier === null && @@ -320,41 +327,35 @@ function mapTournamentFromDB( minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4, modes: null, hasVods: (tournament.vodCount ?? 0) > 0, - firstPlacer: - highestDivWinners.length > 0 - ? { - teamName: highestDivWinners[0].teamName, - logoUrl: - highestDivWinners[0].teamLogoUrl ?? - highestDivWinners[0].pickupAvatarUrl, - div: highestDivWinners[0].div, - members: highestDivWinners - .slice(0, MEMBERS_TO_SHOW) - .map((firstPlacer) => ({ - customUrl: firstPlacer.customUrl, - discordAvatar: firstPlacer.discordAvatar, - discordId: firstPlacer.discordId, - id: firstPlacer.id, - username: firstPlacer.username, - country: firstPlacer.country, - })), - notShownMembersCount: - highestDivWinners.length > MEMBERS_TO_SHOW - ? highestDivWinners.length - MEMBERS_TO_SHOW - : 0, - } - : null, + firstPlacers, }; } -function resolveHighestDivisionWinners( +type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number]; + +function resolveFirstPlacers( tournament: TournamentRepository.ForShowcase, -) { +): ShowcaseCalendarEvent["firstPlacers"] { if (tournament.firstPlacers.length === 0) { return []; } - // not a "many starting brackets" tournament + if ( + Progression.hasAbDivisionsFinals(tournament.settings.bracketProgression) + ) { + const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? ""); + return Object.values(byDiv) + .map((rows) => buildFirstPlacerEntry(rows, { withMembers: false })) + .sort((a, b) => (a.div ?? "").localeCompare(b.div ?? "")); + } + + const winnerRows = winnersOfHighestDivision(tournament); + return [buildFirstPlacerEntry(winnerRows, { withMembers: true })]; +} + +function winnersOfHighestDivision( + tournament: TournamentRepository.ForShowcase, +): FirstPlacerRow[] { if (tournament.firstPlacers.every((p) => p.div === null)) { return tournament.firstPlacers; } @@ -363,8 +364,6 @@ function resolveHighestDivisionWinners( 0, tournament.settings.bracketProgression, ); - - // Filter to only include winners from the highest division const highestDivWinners = tournament.firstPlacers.filter( (p) => p.div === highestDivName, ); @@ -374,6 +373,34 @@ function resolveHighestDivisionWinners( : tournament.firstPlacers; } +function buildFirstPlacerEntry( + rows: FirstPlacerRow[], + { withMembers }: { withMembers: boolean }, +): ShowcaseCalendarEvent["firstPlacers"][number] { + const first = rows[0]; + const members = withMembers + ? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({ + customUrl: row.customUrl, + discordAvatar: row.discordAvatar, + discordId: row.discordId, + id: row.id, + username: row.username, + country: row.country, + })) + : []; + + return { + teamName: first.teamName, + logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl, + div: first.div, + members, + notShownMembersCount: + withMembers && rows.length > MEMBERS_TO_SHOW + ? rows.length - MEMBERS_TO_SHOW + : 0, + }; +} + function databaseTimestampWeekFromNow() { const now = new Date(); diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index a4f1b672e..d180a7bc5 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -71,8 +71,8 @@ function SeasonDates({ return isHydrated ? (
- {formatDate(season.starts, { month: "long", day: "numeric" })} -{" "} - {formatDate(season.ends, { month: "long", day: "numeric" })} + {formatDate(season.starts, { month: "numeric", day: "numeric" })} -{" "} + {formatDate(season.ends, { month: "numeric", day: "numeric" })}
) : (
X
diff --git a/app/features/leaderboards/LeaderboardRepository.server.ts b/app/features/leaderboards/LeaderboardRepository.server.ts index 435e5db9c..a3147eb56 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.ts @@ -410,12 +410,7 @@ export async function seasonPopularUsersWeapon( .with("q1", (db) => db .selectFrom("ReportedWeapon") - .innerJoin( - "GroupMatchMap", - "ReportedWeapon.groupMatchMapId", - "GroupMatchMap.id", - ) - .innerJoin("GroupMatch", "GroupMatchMap.matchId", "GroupMatch.id") + .innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id") .select(({ fn }) => [ "ReportedWeapon.userId", "ReportedWeapon.weaponSplId", diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx index 4754c9867..657fbcfd1 100644 --- a/app/features/lfg/components/LFGPost.tsx +++ b/app/features/lfg/components/LFGPost.tsx @@ -272,7 +272,7 @@ function PostTime({ return (
{formatDate(createdAtDate, { - month: "long", + month: "numeric", day: "numeric", })}{" "} {overDayDifferenceBetween ? ( diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx new file mode 100644 index 000000000..55f3618a6 --- /dev/null +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -0,0 +1,732 @@ +import { ArrowLeft, Ban, Undo2 } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouTab, + SendouTabList, + SendouTabs, +} from "~/components/elements/Tabs"; +import { Main } from "~/components/Main"; +import { MatchActionPickBanTab } from "~/components/match-page/MatchActionPickBanTab"; +import { MatchActionTab } from "~/components/match-page/MatchActionTab"; +import { + IconBanner, + 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 { MatchResultTab } from "~/components/match-page/MatchResultTab"; +import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; +import { MatchTabs } from "~/components/match-page/MatchTabs"; +import { logger } from "~/utils/logger"; +import type { SendouRouteHandle } from "~/utils/remix.server"; + +type ActionVariant = + | "winner" + | "counterpick-stage" + | "ban-stage" + | "ban-stage-only" + | "pick-mode" + | "ban-mode"; + +export const handle: SendouRouteHandle = { + i18n: ["q"], +}; + +export default function MatchPageTestRoute() { + const { t } = useTranslation(["q"]); + const [actionVariant, setActionVariant] = useState("winner"); + + return ( +
+ + }> + Back to bracket + + } + > + Round 2.1 + + + setActionVariant(key as ActionVariant)} + disappearing={false} + padded={false} + > + + Winner + Counterpick + Ban stage + Ban stage (any mode) + Pick mode + Ban mode + + + + + + } + header={t("q:match.cancelRequested")} + subtitle={t("q:match.cancelRequested.subtitle", { + teamName: "Chimera", + })} + screenLegal={false} + /> + + + + + + { + logger.info("onSubbedOutChange", { teamId, subbedOut }); + }} + teams={[ + { + team: { + id: 1, + name: "me in japan", + url: "/t/me-in-japan", + }, + tier: { name: "DIAMOND", isPlus: true }, + members: [ + { + id: 1, + username: "Sendou", + discordId: "123", + discordAvatar: null, + customUrl: "sendou", + tier: { name: "LEVIATHAN", isPlus: true }, + plusTier: 1, + weaponPool: [0, 2000, 4000], + }, + { + id: 2, + username: "Lean", + discordId: "456", + discordAvatar: null, + customUrl: null, + tier: { name: "DIAMOND", isPlus: false }, + plusTier: 2, + weaponPool: [20, 1100], + }, + { + id: 3, + username: "Kiver", + discordId: "789", + discordAvatar: null, + customUrl: null, + tier: "CALCULATING", + }, + { + id: 4, + username: "Brian", + discordId: "012", + discordAvatar: null, + customUrl: null, + }, + { + id: 9, + username: "Poppy", + discordId: "567", + discordAvatar: null, + customUrl: null, + tier: { name: "GOLD", isPlus: true }, + }, + ], + subbedOut: [9], + }, + { + defaultName: "Group Bravo", + members: [ + { + id: 5, + username: "Naga", + discordId: "345", + discordAvatar: null, + customUrl: null, + tier: { name: "PLATINUM", isPlus: false }, + plusTier: 3, + weaponPool: [40, 3000], + }, + { + id: 6, + username: "Grey", + discordId: "678", + discordAvatar: null, + customUrl: null, + tier: { name: "SILVER", isPlus: true }, + }, + { + id: 7, + username: "Zack", + discordId: "901", + discordAvatar: null, + customUrl: null, + }, + { + id: 8, + username: "Lime", + discordId: "234", + discordAvatar: null, + customUrl: null, + tier: { name: "BRONZE", isPlus: false }, + }, + ], + }, + ]} + /> + {actionVariant === "winner" ? ( + } + > + {t("q:match.undoReport")} + + } + /> + ) : actionVariant === "counterpick-stage" ? ( + logger.info("pick submit", data)} + /> + ) : actionVariant === "ban-stage" ? ( + logger.info("ban submit", data)} + /> + ) : actionVariant === "ban-stage-only" ? ( + logger.info("ban stage-only submit", data)} + /> + ) : actionVariant === "pick-mode" ? ( + logger.info("pick mode submit", data)} + /> + ) : ( + logger.info("ban mode submit", data)} + /> + )} + + + +
+ ); +} diff --git a/app/features/mmr/SkillRepository.server.ts b/app/features/mmr/SkillRepository.server.ts index e4b746961..0ccc28bec 100644 --- a/app/features/mmr/SkillRepository.server.ts +++ b/app/features/mmr/SkillRepository.server.ts @@ -1,7 +1,38 @@ -import { sql } from "kysely"; +import { sql, type Transaction } from "kysely"; +import { ordinal } from "openskill"; import { db } from "~/db/sql"; +import type { DB } from "~/db/tables"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants"; +export async function addInitialSkill( + { + mu, + sigma, + season, + userId, + }: { + mu: number; + sigma: number; + season: number; + userId: number; + }, + trx?: Transaction, +) { + const executor = trx ?? db; + + await executor + .insertInto("Skill") + .values({ + mu, + sigma, + season, + ordinal: ordinal({ mu, sigma }), + userId, + matchesCount: 0, + }) + .execute(); +} + export async function seasonProgressionByUserId({ userId, season, diff --git a/app/features/notifications/components/NotificationList.tsx b/app/features/notifications/components/NotificationList.tsx index 3d2b29d99..2ec373477 100644 --- a/app/features/notifications/components/NotificationList.tsx +++ b/app/features/notifications/components/NotificationList.tsx @@ -4,7 +4,6 @@ import { Link } from "react-router"; import { Image } from "~/components/Image"; import type { LoaderNotification } from "~/components/layout/NotificationPopover"; import { - mapMetaForTranslation, notificationLink, notificationNavIcon, } from "~/features/notifications/notifications-utils"; @@ -23,7 +22,7 @@ export function NotificationItem({ notification: LoaderNotification; onClose?: () => void; }) { - const { t, i18n } = useTranslation(["common"]); + const { t } = useTranslation(["common"]); return ( : null}
- {t( - `common:notifications.text.${notification.type}`, - mapMetaForTranslation(notification, i18n.language), - )} + {t(`common:notifications.text.${notification.type}`, notification.meta)}
{formatDistance( diff --git a/app/features/notifications/core/notify.server.test.ts b/app/features/notifications/core/notify.server.test.ts index 3cb31ac2a..3e0126f52 100644 --- a/app/features/notifications/core/notify.server.test.ts +++ b/app/features/notifications/core/notify.server.test.ts @@ -139,7 +139,7 @@ describe("notify()", () => { userIds: [10, 11], notification: { type: "SCRIM_SCHEDULED", - meta: { id: 1, at: 123 }, + meta: { id: 1, opponentTeamName: "Alpha" }, }, }); @@ -147,7 +147,7 @@ describe("notify()", () => { userIds: [10, 11], notification: { type: "SCRIM_CANCELED", - meta: { id: 1, at: 123 }, + meta: { id: 1, opponentTeamName: "Alpha" }, }, }); @@ -346,7 +346,7 @@ describe("notify() - web push notifications", () => { expect(mockSendNotification).not.toHaveBeenCalled(); }); - test("formats timestamp for scrim notifications", async () => { + test("includes opponent team name for scrim notifications", async () => { const mockSubscription = { endpoint: "https://fcm.googleapis.com/fcm/send/test", keys: { @@ -367,13 +367,11 @@ describe("notify() - web push notifications", () => { mockWebPushEnabled.value = true; - const testTimestamp = new Date("2024-01-15T15:30:00Z").getTime(); - await notify({ userIds: [1], notification: { type: "SCRIM_SCHEDULED", - meta: { id: 1, at: testTimestamp }, + meta: { id: 1, opponentTeamName: "Sendou's pickup" }, }, }); @@ -383,8 +381,6 @@ describe("notify() - web push notifications", () => { const payload = JSON.parse(callArgs); expect(payload.title).toBe("Scrim Scheduled"); - expect(payload.body).toMatch( - /New scrim scheduled at \d+\/\d+, \d+:\d+ (AM|PM)/, - ); + expect(payload.body).toBe("New scrim scheduled vs. Sendou's pickup"); }); }); diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index 1f97022d7..b1e7b13e5 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -7,10 +7,7 @@ import { i18next } from "../../../modules/i18n/i18next.server"; import { logger } from "../../../utils/logger"; import * as NotificationRepository from "../NotificationRepository.server"; import type { Notification } from "../notifications-types"; -import { - mapMetaForTranslation, - notificationLink, -} from "../notifications-utils"; +import { notificationLink } from "../notifications-utils"; import webPush, { webPushEnabled } from "./webPush.server"; const NOTIFICATION_URGENCY: Record = { @@ -168,10 +165,12 @@ function pushNotificationOptions( ): Parameters[1] & { title: string; } { - const meta = mapMetaForTranslation(notification, "en-US"); return { title: t(`common:notifications.title.${notification.type}`), - body: t(`common:notifications.text.${notification.type}`, meta), + body: t( + `common:notifications.text.${notification.type}`, + notification.meta, + ), icon: notification.pictureUrl ?? "/static-assets/img/app-icon.png", data: { url: notificationLink(notification) }, }; diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts index 4519d9e1e..f213d2a1e 100644 --- a/app/features/notifications/notifications-types.ts +++ b/app/features/notifications/notifications-types.ts @@ -62,9 +62,15 @@ export type Notification = > | NotificationItem<"SEASON_STARTED", { seasonNth: number }> | NotificationItem<"SCRIM_NEW_REQUEST", { fromUsername: string }> - | NotificationItem<"SCRIM_SCHEDULED", { id: number; at: number }> - | NotificationItem<"SCRIM_CANCELED", { id: number; at: number }> - | NotificationItem<"SCRIM_STARTING_SOON", { id: number; at: number }> + | NotificationItem< + "SCRIM_SCHEDULED", + { id: number; opponentTeamName: string } + > + | NotificationItem<"SCRIM_CANCELED", { id: number; opponentTeamName: string }> + | NotificationItem< + "SCRIM_STARTING_SOON", + { id: number; opponentTeamName: string } + > | NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }> | NotificationItem<"FRIEND_REQUEST_RECEIVED", { senderUsername: string }> | NotificationItem< diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts index be53272be..642cb87a6 100644 --- a/app/features/notifications/notifications-utils.ts +++ b/app/features/notifications/notifications-utils.ts @@ -107,27 +107,3 @@ export const notificationLink = (notification: Notification) => { assertUnreachable(notification); } }; - -/** Takes the `meta` object of a notification and transforms it (if needed) to show the translated string to user */ -export const mapMetaForTranslation = ( - notification: Notification, - language: string, -) => { - if ( - notification.type === "SCRIM_SCHEDULED" || - notification.type === "SCRIM_CANCELED" || - notification.type === "SCRIM_STARTING_SOON" - ) { - return { - ...notification.meta, - timeString: new Date(notification.meta.at).toLocaleString(language, { - day: "numeric", - month: "numeric", - hour: "numeric", - minute: "numeric", - }), - }; - } - - return notification.meta; -}; diff --git a/app/features/plus-voting/routes/plus.voting.results.tsx b/app/features/plus-voting/routes/plus.voting.results.tsx index 4ed84bab6..d911a8e4b 100644 --- a/app/features/plus-voting/routes/plus.voting.results.tsx +++ b/app/features/plus-voting/routes/plus.voting.results.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import type { MetaFunction } from "react-router"; import { Link, useLoaderData } from "react-router"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import { PLUS_SERVER_DISCORD_URL, userPage } from "~/utils/urls"; @@ -21,12 +22,22 @@ export const meta: MetaFunction = (args) => { export default function PlusVotingResultsPage() { const data = useLoaderData(); + const { formatDate } = useTimeFormat(); return (

- Voting results for {data.lastCompletedVoting.month + 1}/ - {data.lastCompletedVoting.year} + Voting results for{" "} + {formatDate( + new Date( + data.lastCompletedVoting.year, + data.lastCompletedVoting.month, + ), + { + month: "numeric", + year: "numeric", + }, + )}

{data.ownScores && data.ownScores.length > 0 ? ( <> diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index 216332c0d..be1c79f6b 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -152,7 +152,11 @@ const baseFindQuery = db eb .selectFrom("ScrimPostUser") .innerJoin("User", "ScrimPostUser.userId", "User.id") - .select([...COMMON_USER_FIELDS, "ScrimPostUser.isOwner"]) + .select([ + ...COMMON_USER_FIELDS, + "User.inGameName", + "ScrimPostUser.isOwner", + ]) .whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id"), ).as("users"), jsonArrayFrom( @@ -181,7 +185,11 @@ const baseFindQuery = db innerEb .selectFrom("ScrimPostRequestUser") .innerJoin("User", "ScrimPostRequestUser.userId", "User.id") - .select([...COMMON_USER_FIELDS, "ScrimPostRequestUser.isOwner"]) + .select([ + ...COMMON_USER_FIELDS, + "User.inGameName", + "ScrimPostRequestUser.isOwner", + ]) .whereRef( "ScrimPostRequestUser.scrimPostRequestId", "=", diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index aa45f25c7..d78e2c1c2 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -8,10 +8,7 @@ import { parseRequestPayload, } from "~/utils/remix.server"; import { idObject } from "~/utils/zod"; -import { - databaseTimestampToDate, - databaseTimestampToJavascriptTimestamp, -} from "../../../utils/dates"; +import { databaseTimestampToDate } from "../../../utils/dates"; import { errorToast } from "../../../utils/remix.server"; import { requireUser } from "../../auth/core/user.server"; import * as Scrim from "../core/Scrim"; @@ -42,17 +39,29 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { reason: data.reason, }); - notify({ - userIds: Scrim.participantIdsListFromAccepted(post), - defaultSeenUserIds: [user.id], - notification: { - type: "SCRIM_CANCELED", - meta: { - id: post.id, - at: databaseTimestampToJavascriptTimestamp(Scrim.getStartTime(post)), + const acceptedRequest = post.requests.find((r) => r.isAccepted); + if (acceptedRequest) { + const postTeamName = Scrim.sideDisplayName(post); + const requestTeamName = Scrim.sideDisplayName(acceptedRequest); + + notify({ + userIds: post.users.map((m) => m.id), + defaultSeenUserIds: [user.id], + notification: { + type: "SCRIM_CANCELED", + meta: { id: post.id, opponentTeamName: requestTeamName }, }, - }, - }); + }); + + notify({ + userIds: acceptedRequest.users.map((m) => m.id), + defaultSeenUserIds: [user.id], + notification: { + type: "SCRIM_CANCELED", + meta: { id: post.id, opponentTeamName: postTeamName }, + }, + }); + } return null; }; diff --git a/app/features/scrims/actions/scrims.server.ts b/app/features/scrims/actions/scrims.server.ts index 260bf369b..e1a29dff1 100644 --- a/app/features/scrims/actions/scrims.server.ts +++ b/app/features/scrims/actions/scrims.server.ts @@ -11,7 +11,6 @@ import * as UserRepository from "~/features/user-page/UserRepository.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { databaseTimestampToDate, - databaseTimestampToJavascriptTimestamp, dateToDatabaseTimestamp, } from "~/utils/dates"; import { ConcurrentModificationError } from "~/utils/errors"; @@ -157,18 +156,24 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); } + const postTeamName = Scrim.sideDisplayName(post); + const requestTeamName = Scrim.sideDisplayName(request); + notify({ - userIds: [ - ...post.users.map((m) => m.id), - ...request.users.map((m) => m.id), - ], + userIds: post.users.map((m) => m.id), defaultSeenUserIds: [user.id], notification: { type: "SCRIM_SCHEDULED", - meta: { - id: post.id, - at: databaseTimestampToJavascriptTimestamp(request.at ?? post.at), - }, + meta: { id: post.id, opponentTeamName: requestTeamName }, + }, + }); + + notify({ + userIds: request.users.map((m) => m.id), + defaultSeenUserIds: [user.id], + notification: { + type: "SCRIM_SCHEDULED", + meta: { id: post.id, opponentTeamName: postTeamName }, }, }); diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index 9b02e5d9e..d73c334dc 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -405,7 +405,7 @@ function ScrimActionButtons({ hour: "numeric", minute: "2-digit", day: "numeric", - month: "long", + month: "numeric", })}
diff --git a/app/features/scrims/components/ScrimMatchBanner.tsx b/app/features/scrims/components/ScrimMatchBanner.tsx new file mode 100644 index 000000000..c1552f35e --- /dev/null +++ b/app/features/scrims/components/ScrimMatchBanner.tsx @@ -0,0 +1,86 @@ +import { Ban, Swords } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Link, useLoaderData } from "react-router"; +import { Image } from "~/components/Image"; +import { + IconBanner, + MatchBannerContainer, +} from "~/components/match-page/MatchBanner"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { logger } from "~/utils/logger"; +import type { SerializeFrom } from "~/utils/remix"; +import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls"; +import type { loader } from "../loaders/scrims.$id.server"; +import type { ScrimPost } from "../scrims-types"; + +export function ScrimMatchBanner() { + const { t } = useTranslation(["scrims"]); + const data = useLoaderData(); + + const screenLegal = !data.anyUserPrefersNoScreen; + + if (data.post.canceled) { + return ( + + } + header={t("scrims:banner.canceled.header", { + user: data.post.canceled.byUser.username, + })} + subtitle={t("scrims:banner.canceled.subtitle", { + reason: data.post.canceled.reason, + })} + /> + + ); + } + + const hasMaps = data.post.maps || data.tournamentMapPool; + + return ( + + } + header={t("scrims:banner.freeForm.header")} + subtitle={t("scrims:banner.freeForm.subtitle")} + screenLegal={screenLegal} + topRight={ + hasMaps ? ( + + ) : undefined + } + /> + + ); +} + +function MapsLink({ + maps, + tournamentMapPool, +}: Pick & + Pick, "tournamentMapPool">) { + const mapPool = () => { + if (tournamentMapPool) return new MapPool(tournamentMapPool); + + if (maps === "SZ") return MapPool.SZ; + if (maps === "RANKED") return MapPool.ANARCHY; + if (maps === "ALL") return MapPool.ALL; + + logger.info(`Unknown scrim maps value: ${maps}`); + return MapPool.ALL; + }; + + return ( + + Generate maplist + + ); +} diff --git a/app/features/scrims/components/ScrimMatchHeader.tsx b/app/features/scrims/components/ScrimMatchHeader.tsx new file mode 100644 index 000000000..de6546bc5 --- /dev/null +++ b/app/features/scrims/components/ScrimMatchHeader.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { MatchPageHeader } from "~/components/match-page/MatchPageHeader"; +import TimePopover from "~/components/TimePopover"; +import { SendouForm } from "~/form/SendouForm"; +import { useHasPermission } from "~/modules/permissions/hooks"; +import { databaseTimestampToDate } from "~/utils/dates"; +import type { loader } from "../loaders/scrims.$id.server"; +import { cancelScrimSchema } from "../scrims-schemas"; + +export function ScrimMatchHeader() { + const { t } = useTranslation(["common", "scrims"]); + const data = useLoaderData(); + + const allowedToCancel = useHasPermission(data.post, "CANCEL"); + const isCanceled = Boolean(data.post.canceled); + const acceptedRequest = data.post.requests.find((r) => r.isAccepted); + const scrimTime = acceptedRequest?.at ?? data.post.at; + const canCancel = + allowedToCancel && + !isCanceled && + databaseTimestampToDate(data.post.at) > new Date(); + + return ( + + {t("common:actions.cancel")} + + } + heading={t("scrims:cancelModal.scrim.title")} + showCloseButton + > + + + ) : undefined + } + > + + + ); +} + +function CancelScrimForm() { + return ( + + {({ FormField }) => } + + ); +} diff --git a/app/features/scrims/components/ScrimMatchTabs.tsx b/app/features/scrims/components/ScrimMatchTabs.tsx new file mode 100644 index 000000000..80cc4dedf --- /dev/null +++ b/app/features/scrims/components/ScrimMatchTabs.tsx @@ -0,0 +1,76 @@ +import { sub } from "date-fns"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { MatchJoinTab } from "~/components/match-page/MatchJoinTab"; +import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; +import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { resolveRoomPass } from "~/components/match-page/utils"; +import { useUser } from "~/features/auth/core/user"; +import { + resolveActiveRoomLink, + useConfirmRoom, +} from "~/features/chat/room-link-utils"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { teamPage } from "~/utils/urls"; +import * as Scrim from "../core/Scrim"; +import type { loader } from "../loaders/scrims.$id.server"; +import type { ScrimPost } from "../scrims-types"; + +const SCRIM_ROOM_LINK_FRESHNESS_MINUTES = 30; + +export function ScrimMatchTabs() { + const { t } = useTranslation(["q"]); + const user = useUser(); + const data = useLoaderData(); + const { onConfirmRoom, isConfirming } = useConfirmRoom(); + + const acceptedRequest = data.post.requests[0]; + const allMembers = [...data.post.users, ...acceptedRequest.users]; + + const activeRoomLink = resolveActiveRoomLink({ + roomLinks: data.roomLinks, + freshnessCutoff: dateToDatabaseTimestamp( + sub(new Date(), { minutes: SCRIM_ROOM_LINK_FRESHNESS_MINUTES }), + ), + viewerUserId: user?.id, + members: allMembers, + }); + + return ( + + + + + ); +} + +function mapTeam(team: ScrimPost["team"]) { + if (!team) return undefined; + return { + id: 0, + name: team.name, + url: teamPage(team.customUrl), + avatar: team.avatarUrl ?? undefined, + }; +} diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index afdf51f9f..602a96b48 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import type { ScrimFilters, ScrimPost } from "../scrims-types"; -import { applyFilters, participantIdsListFromAccepted } from "./Scrim"; +import { + applyFilters, + participantIdsListFromAccepted, + sideDisplayName, +} from "./Scrim"; type MockUser = { id: number }; type MockRequest = { isAccepted: boolean; users: MockUser[] }; @@ -78,6 +82,27 @@ describe("participantIdsListFromAccepted", () => { }); }); +describe("sideDisplayName", () => { + it("returns the team name when team is set", () => { + const result = sideDisplayName({ + team: { name: "Team Olive" }, + users: [{ username: "sendou", isOwner: true }], + }); + expect(result).toBe("Team Olive"); + }); + + it("falls back to {owner}'s pickup when team is null", () => { + const result = sideDisplayName({ + team: null, + users: [ + { username: "alice", isOwner: false }, + { username: "sendou", isOwner: true }, + ], + }); + expect(result).toBe("sendou's pickup"); + }); +}); + describe("applyFilters", () => { function createPostForFilters( at: Date, diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index e5ca8f842..1a45a425d 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -50,6 +50,19 @@ export function getStartTime(post: ScrimPost): number { return acceptedRequest?.at ?? post.at; } +/** + * Returns a display name for a scrim side: the team name when set, + * otherwise "{ownerUsername}'s pickup". + */ +export function sideDisplayName(side: { + team: { name: string } | null; + users: Array<{ username: string; isOwner: boolean }>; +}): string { + if (side.team) return side.team.name; + const owner = side.users.find((u) => u.isOwner) ?? side.users[0]; + return `${owner.username}'s pickup`; +} + export function applyFilters(post: ScrimPost, filters: ScrimFilters): boolean { const hasMinFilter = filters.divs?.min !== null; const hasMaxFilter = filters.divs?.max !== null; diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index e7486b1fe..127684040 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server"; import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -28,6 +29,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const participantIds = Scrim.participantIdsListFromAccepted(post); + const [anyUserPrefersNoScreen, anyUserPrefersNoSplatnet, roomLinks] = + await Promise.all([ + UserRepository.anyUserPrefersNoScreen(participantIds), + UserRepository.anyUserPrefersNoSplatnet(participantIds), + RoomLinkRepository.findByUserIds(participantIds, 3), + ]); + return { post, chatCode: @@ -39,8 +47,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }) ? post.chatCode : undefined, - anyUserPrefersNoScreen: - await UserRepository.anyUserPrefersNoScreen(participantIds), + anyUserPrefersNoScreen, + anyUserPrefersNoSplatnet, + roomLinks, tournamentMapPool: post.mapsTournament ? await resolveTournamentMapPool(post.mapsTournament.id, user) : null, diff --git a/app/features/scrims/routes/scrims.$id.module.css b/app/features/scrims/routes/scrims.$id.module.css deleted file mode 100644 index 36f22cacf..000000000 --- a/app/features/scrims/routes/scrims.$id.module.css +++ /dev/null @@ -1,91 +0,0 @@ -.groupsContainer { - display: grid; - grid-template-columns: 1fr; - gap: var(--s-8); -} - -@container (width >= 640px) { - .groupsContainer { - grid-template-columns: 1fr 1fr; - } -} - -.groupCard { - border-radius: var(--radius-box); - background-color: var(--color-bg-high); - padding: var(--s-3); - display: flex; - gap: var(--s-4); - flex-direction: column; -} - -.memberRow { - display: flex; - gap: var(--s-2); - align-items: center; - background-color: var(--color-bg); - border-radius: var(--radius-box); - font-size: var(--font-sm); - font-weight: var(--weight-semi); - padding-inline-end: var(--s-3); - color: var(--color-text); -} - -.infoHeader { - text-transform: uppercase; - color: var(--color-text-high); - font-size: var(--font-xs); - line-height: 1.1; - font-weight: var(--weight-semi); -} - -.infoValue { - font-size: var(--font-xl); - font-weight: var(--weight-semi); - letter-spacing: 1px; -} - -.screenBanIndicator { - display: flex; - align-items: center; - gap: var(--s-1); - - & svg { - color: var(--color-success); - } -} - -.screenBanImageWrapper { - position: relative; - display: inline-block; - line-height: 0; - - & picture { - flex-shrink: 0; - } -} - -.screenBanIconOverlay { - position: absolute; - bottom: 0; - right: 0; - background-color: var(--color-bg-higher); - border-radius: 50%; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - - & svg { - width: 14px; - height: 14px; - color: var(--color-bg); - } -} - -.screenBanIndicatorWarning { - & svg { - color: var(--color-error); - } -} diff --git a/app/features/scrims/routes/scrims.$id.tsx b/app/features/scrims/routes/scrims.$id.tsx index 661e48977..d962ee2a7 100644 --- a/app/features/scrims/routes/scrims.$id.tsx +++ b/app/features/scrims/routes/scrims.$id.tsx @@ -1,43 +1,15 @@ -import clsx from "clsx"; -import { useTranslation } from "react-i18next"; -import { Link, useLoaderData } from "react-router"; -import { Alert } from "~/components/Alert"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouDialog } from "~/components/elements/Dialog"; -import { SendouPopover } from "~/components/elements/Popover"; -import { Image } from "~/components/Image"; -import TimePopover from "~/components/TimePopover"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { cancelScrimSchema } from "~/features/scrims/scrims-schemas"; -import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils"; -import { SendouForm } from "~/form/SendouForm"; -import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; -import { useHasPermission } from "~/modules/permissions/hooks"; -import type { SerializeFrom } from "~/utils/remix"; +import { Main } from "~/components/Main"; +import { MatchPage } from "~/components/match-page/MatchPage"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { Avatar } from "../../../components/Avatar"; -import { Main } from "../../../components/Main"; -import { databaseTimestampToDate } from "../../../utils/dates"; -import { logger } from "../../../utils/logger"; -import { - BLANK_IMAGE_URL, - mapsPageWithMapPool, - navIconUrl, - scrimsPage, - specialWeaponImageUrl, - teamPage, - userPage, -} from "../../../utils/urls"; +import { navIconUrl, scrimsPage } from "../../../utils/urls"; import { action } from "../actions/scrims.$id.server"; -import * as Scrim from "../core/Scrim"; +import { ScrimMatchBanner } from "../components/ScrimMatchBanner"; +import { ScrimMatchHeader } from "../components/ScrimMatchHeader"; +import { ScrimMatchTabs } from "../components/ScrimMatchTabs"; import { loader } from "../loaders/scrims.$id.server"; -import type { ScrimPost, ScrimPost as ScrimPostType } from "../scrims-types"; -import styles from "./scrims.$id.module.css"; export { action, loader }; -import { Check, OctagonAlert } from "lucide-react"; - export const handle: SendouRouteHandle = { i18n: ["scrims", "q"], breadcrumb: () => ({ @@ -48,229 +20,13 @@ export const handle: SendouRouteHandle = { }; export default function ScrimPage() { - const { t } = useTranslation(["q", "scrims", "common"]); - const data = useLoaderData(); - - const allowedToCancel = useHasPermission(data.post, "CANCEL"); - const isCanceled = Boolean(data.post.canceled); - const canCancel = - allowedToCancel && - !isCanceled && - databaseTimestampToDate(data.post.at) > new Date(); - return ( -
-
- - {canCancel && ( -
- - {t("common:actions.cancel")} - - } - heading={t("scrims:cancelModal.scrim.title")} - showCloseButton - > - - -
- )} -
- {data.post.canceled && ( -
- - {t("scrims:alert.canceled", { - user: data.post.canceled.byUser.username, - reason: data.post.canceled.reason, - })} - -
- )} -
- - -
-
- - - - {data.post.maps || data.tournamentMapPool ? ( - - ) : null} -
+
+ + + + +
); } - -function CancelScrimForm() { - return ( - - {({ FormField }) => } - - ); -} - -function ScrimHeader() { - const { t } = useTranslation(["scrims"]); - const data = useLoaderData(); - - const acceptedRequest = data.post.requests.find((r) => r.isAccepted); - const scrimTime = acceptedRequest?.at ?? data.post.at; - - return ( -
-

- -

-
- {t("scrims:page.scheduledScrim")} -
-
- ); -} - -function GroupCard({ - group, - side, -}: { - group: { users: ScrimPostType["users"]; team: ScrimPostType["team"] }; - side: "ALPHA" | "BRAVO"; -}) { - const { t } = useTranslation(["q"]); - - return ( -
-
-
- {side === "ALPHA" - ? t("q:match.sides.alpha") - : t("q:match.sides.bravo")} -
- {group.team ? ( - - - {group.team.name} - - ) : null} -
-
- {group.users.map((user) => ( - - - {user.username} - - ))} -
-
- ); -} - -function InfoWithHeader({ header, value }: { header: string; value: string }) { - return ( -
-
{header}
-
{value}
-
- ); -} - -function ScreenBanIndicator() { - const { t } = useTranslation(["weapons", "scrims"]); - const data = useLoaderData(); - - return ( -
-
{t("scrims:screenBan.header")}
-
- -
- {t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)} -
- {data.anyUserPrefersNoScreen ? : } -
-
- - } - > -
- {data.anyUserPrefersNoScreen - ? t("scrims:screenBan.warning") - : t("scrims:screenBan.allowed")} -
-
-
-
- ); -} - -function MapsLink({ - maps, - tournamentMapPool, -}: Pick & - Pick, "tournamentMapPool">) { - const { t } = useTranslation(["scrims"]); - - const mapPool = () => { - if (tournamentMapPool) return new MapPool(tournamentMapPool); - - if (maps === "SZ") return MapPool.SZ; - if (maps === "RANKED") return MapPool.ANARCHY; - if (maps === "ALL") return MapPool.ALL; - - logger.info(`Unknown scrim maps value: ${maps}`); - return MapPool.ALL; - }; - - return ( -
-
{t("scrims:maps.header")}
- - Generate maplist - -
- ); -} diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index 17ec84c37..d27a4e1d9 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -216,7 +216,7 @@ function ScrimsDaySection({

{formatDate(databaseTimestampToDate(posts[0].at), { day: "numeric", - month: "long", + month: "numeric", weekday: "long", })}

@@ -342,7 +342,7 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {

{formatDate(databaseTimestampToDate(posts![0].at), { day: "numeric", - month: "long", + month: "numeric", weekday: "long", })}

@@ -411,7 +411,7 @@ function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {

{formatDate(databaseTimestampToDate(posts![0].at), { day: "numeric", - month: "long", + month: "numeric", weekday: "long", })}

diff --git a/app/features/scrims/scrims-types.ts b/app/features/scrims/scrims-types.ts index 002d08fd2..7781f8d98 100644 --- a/app/features/scrims/scrims-types.ts +++ b/app/features/scrims/scrims-types.ts @@ -59,6 +59,7 @@ export interface ScrimPostRequest { export interface ScrimPostUser extends CommonUser { isOwner: boolean; + inGameName: string | null; } interface ScrimPostTeam { diff --git a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts new file mode 100644 index 000000000..088f8d1ef --- /dev/null +++ b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as GroupMatchContinueVoteRepository from "./GroupMatchContinueVoteRepository.server"; + +const insertGroup = async () => { + const group = await db + .insertInto("Group") + .values({ + inviteCode: `inv-${Math.random().toString(36).slice(2, 10)}`, + chatCode: `chat-${Math.random().toString(36).slice(2, 10)}`, + status: "ACTIVE", + }) + .returning("id") + .executeTakeFirstOrThrow(); + return group.id; +}; + +const fetchVotes = (groupId: number) => + db + .selectFrom("GroupMatchContinueVote") + .selectAll() + .where("groupId", "=", groupId) + .execute(); + +describe("findForGroups", () => { + beforeEach(async () => { + await dbInsertUsers(4); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns empty array without querying when no group ids given", async () => { + const result = await GroupMatchContinueVoteRepository.findForGroups([]); + expect(result).toEqual([]); + }); + + test("returns votes only for the requested groups with isContinuing as boolean", async () => { + const groupA = await insertGroup(); + const groupB = await insertGroup(); + const groupC = await insertGroup(); + + await GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + userId: 1, + isContinuing: 1, + }); + await GroupMatchContinueVoteRepository.cast({ + groupId: groupB, + userId: 2, + isContinuing: 0, + }); + await GroupMatchContinueVoteRepository.cast({ + groupId: groupC, + userId: 3, + isContinuing: 1, + }); + + const result = await GroupMatchContinueVoteRepository.findForGroups([ + groupA, + groupB, + ]); + + expect(result).toHaveLength(2); + const groupAVote = result.find((v) => v.groupId === groupA); + const groupBVote = result.find((v) => v.groupId === groupB); + expect(groupAVote?.isContinuing).toBe(true); + expect(groupBVote?.isContinuing).toBe(false); + }); +}); + +describe("cast", () => { + beforeEach(async () => { + await dbInsertUsers(4); + }); + + afterEach(() => { + dbReset(); + }); + + test("updates existing vote on conflict instead of inserting a duplicate", async () => { + const groupId = await insertGroup(); + + await GroupMatchContinueVoteRepository.cast({ + groupId, + userId: 1, + isContinuing: 1, + }); + await GroupMatchContinueVoteRepository.cast({ + groupId, + userId: 1, + isContinuing: 0, + }); + + const votes = await fetchVotes(groupId); + expect(votes).toHaveLength(1); + expect(votes[0].isContinuing).toBe(0); + }); + + test("voting no clears existing yes votes for that group only", async () => { + const groupA = await insertGroup(); + const groupB = await insertGroup(); + + await GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + userId: 1, + isContinuing: 1, + }); + await GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + userId: 2, + isContinuing: 1, + }); + await GroupMatchContinueVoteRepository.cast({ + groupId: groupB, + userId: 1, + isContinuing: 1, + }); + + await GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + userId: 3, + isContinuing: 0, + }); + + const groupAVotes = await fetchVotes(groupA); + expect(groupAVotes).toHaveLength(1); + expect(groupAVotes[0].userId).toBe(3); + expect(groupAVotes[0].isContinuing).toBe(0); + + const groupBVotes = await fetchVotes(groupB); + expect(groupBVotes).toHaveLength(1); + expect(groupBVotes[0].isContinuing).toBe(1); + }); +}); diff --git a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts new file mode 100644 index 000000000..1d4df60b7 --- /dev/null +++ b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts @@ -0,0 +1,67 @@ +import type { Transaction } from "kysely"; +import { db } from "~/db/sql"; +import type { DB, DBBoolean } from "~/db/tables"; + +export async function findForGroups(groupIds: number[], trx?: Transaction) { + if (groupIds.length === 0) return []; + + const executor = trx ?? db; + + const rows = await executor + .selectFrom("GroupMatchContinueVote") + .select([ + "GroupMatchContinueVote.groupId", + "GroupMatchContinueVote.userId", + "GroupMatchContinueVote.isContinuing", + "GroupMatchContinueVote.votedAt", + ]) + .where("GroupMatchContinueVote.groupId", "in", groupIds) + .execute(); + + return rows.map((row) => ({ + ...row, + isContinuing: Boolean(row.isContinuing), + })); +} + +export async function cast( + { + groupId, + userId, + isContinuing, + }: { + groupId: number; + userId: number; + isContinuing: DBBoolean; + }, + trx?: Transaction, +) { + const executor = trx ?? db; + + const runner = async (t: Transaction) => { + if (isContinuing === 0) { + // every vote is only valid for a specific continuing size + // e.g. if i want to keep going with a full group, i might not + // want to continue with just 3 people -> revote required from all + await t + .deleteFrom("GroupMatchContinueVote") + .where("GroupMatchContinueVote.groupId", "=", groupId) + .where("GroupMatchContinueVote.isContinuing", "=", 1) + .execute(); + } + + await t + .insertInto("GroupMatchContinueVote") + .values({ groupId, userId, isContinuing }) + .onConflict((oc) => + oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing }), + ) + .execute(); + }; + + if (trx) { + await runner(trx); + return; + } + await executor.transaction().execute(runner); +} diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts index 4e886255d..9502694e2 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -1,6 +1,9 @@ import type { NotNull, Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, TablesInsertable } from "~/db/tables"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; export function createMany( weapons: TablesInsertable["ReportedWeapon"][], @@ -11,6 +14,28 @@ export function createMany( return (trx ?? db).insertInto("ReportedWeapon").values(weapons).execute(); } +export async function upsertOne({ + groupMatchId, + mapIndex, + userId, + weaponSplId, +}: TablesInsertable["ReportedWeapon"] & { + groupMatchId: number; + mapIndex: number; +}) { + await db + .deleteFrom("ReportedWeapon") + .where("groupMatchId", "=", groupMatchId) + .where("mapIndex", "=", mapIndex) + .where("userId", "=", userId) + .execute(); + + await db + .insertInto("ReportedWeapon") + .values({ groupMatchId, mapIndex, userId, weaponSplId }) + .execute(); +} + export async function replaceByMatchId( matchId: number, weapons: TablesInsertable["ReportedWeapon"][], @@ -18,49 +43,215 @@ export async function replaceByMatchId( ) { const executor = trx ?? db; - const groupMatchMaps = await executor - .selectFrom("GroupMatchMap") - .select("id") - .where("matchId", "=", matchId) + await executor + .deleteFrom("ReportedWeapon") + .where("groupMatchId", "=", matchId) .execute(); - if (groupMatchMaps.length > 0) { - await executor - .deleteFrom("ReportedWeapon") - .where( - "groupMatchMapId", - "in", - groupMatchMaps.map((m) => m.id), - ) - .execute(); - } - if (weapons.length > 0) { await executor.insertInto("ReportedWeapon").values(weapons).execute(); } } +export async function deleteByUserMapIndex({ + matchId, + userId, + mapIndex, +}: { + matchId: number; + userId: number; + mapIndex: number; +}) { + await db + .deleteFrom("ReportedWeapon") + .where("groupMatchId", "=", matchId) + .where("mapIndex", "=", mapIndex) + .where("userId", "=", userId) + .execute(); +} + +export async function deleteByMapIndex( + { + matchId, + mapIndex, + }: { + matchId: number; + mapIndex: number; + }, + trx?: Transaction, +) { + await (trx ?? db) + .deleteFrom("ReportedWeapon") + .where("groupMatchId", "=", matchId) + .where("mapIndex", "=", mapIndex) + .execute(); +} + export async function findByMatchId(matchId: number) { const rows = await db .selectFrom("ReportedWeapon") - .innerJoin( - "GroupMatchMap", - "GroupMatchMap.id", - "ReportedWeapon.groupMatchMapId", - ) .select([ - "ReportedWeapon.groupMatchMapId", + "ReportedWeapon.groupMatchId", + "ReportedWeapon.mapIndex", "ReportedWeapon.weaponSplId", "ReportedWeapon.userId", - "GroupMatchMap.index as mapIndex", ]) - .where("GroupMatchMap.matchId", "=", matchId) - .orderBy("GroupMatchMap.index", "asc") + .where("ReportedWeapon.groupMatchId", "=", matchId) + .orderBy("ReportedWeapon.mapIndex", "asc") .orderBy("ReportedWeapon.userId", "asc") - .$narrowType<{ groupMatchMapId: NotNull }>() + .$narrowType<{ groupMatchId: NotNull }>() .execute(); if (rows.length === 0) return null; return rows; } + +export async function upsertOneTournament({ + tournamentMatchId, + mapIndex, + userId, + weaponSplId, +}: TablesInsertable["ReportedWeapon"] & { + tournamentMatchId: number; + mapIndex: number; +}) { + await db + .deleteFrom("ReportedWeapon") + .where("tournamentMatchId", "=", tournamentMatchId) + .where("mapIndex", "=", mapIndex) + .where("userId", "=", userId) + .execute(); + + await db + .insertInto("ReportedWeapon") + .values({ tournamentMatchId, mapIndex, userId, weaponSplId }) + .execute(); +} + +export async function deleteByUserMapIndexTournament({ + tournamentMatchId, + userId, + mapIndex, +}: { + tournamentMatchId: number; + userId: number; + mapIndex: number; +}) { + await db + .deleteFrom("ReportedWeapon") + .where("tournamentMatchId", "=", tournamentMatchId) + .where("mapIndex", "=", mapIndex) + .where("userId", "=", userId) + .execute(); +} + +export async function deleteByMapIndexTournament({ + tournamentMatchId, + mapIndex, +}: { + tournamentMatchId: number; + mapIndex: number; +}) { + await db + .deleteFrom("ReportedWeapon") + .where("tournamentMatchId", "=", tournamentMatchId) + .where("mapIndex", "=", mapIndex) + .execute(); +} + +export async function findByTournamentMatchId(matchId: number) { + const rows = await db + .selectFrom("ReportedWeapon") + .select([ + "ReportedWeapon.tournamentMatchId", + "ReportedWeapon.mapIndex", + "ReportedWeapon.weaponSplId", + "ReportedWeapon.userId", + ]) + .where("ReportedWeapon.tournamentMatchId", "=", matchId) + .orderBy("ReportedWeapon.mapIndex", "asc") + .orderBy("ReportedWeapon.userId", "asc") + .$narrowType<{ tournamentMatchId: NotNull; mapIndex: NotNull }>() + .execute(); + + if (rows.length === 0) return null; + + return rows; +} + +/** + * Aggregates a user's reported weapons across both SendouQ matches and + * finalized tournaments that fall within the given season's date range. + */ +export async function seasonReportedWeaponsByUserId({ + userId, + season, +}: { + userId: number; + season: number; +}): Promise> { + const { starts, ends } = Seasons.nthToDateRange(season); + const startsTs = dateToDatabaseTimestamp(starts); + const endsTs = dateToDatabaseTimestamp(ends); + + const sendouqWeapons = db + .selectFrom("ReportedWeapon") + .innerJoin("GroupMatch", "GroupMatch.id", "ReportedWeapon.groupMatchId") + .select(({ fn }) => [ + "ReportedWeapon.weaponSplId", + fn.countAll().as("count"), + ]) + .where("ReportedWeapon.userId", "=", userId) + .where("GroupMatch.createdAt", ">=", startsTs) + .where("GroupMatch.createdAt", "<=", endsTs) + .groupBy("ReportedWeapon.weaponSplId"); + + const tournamentWeapons = db + .selectFrom("ReportedWeapon") + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "ReportedWeapon.tournamentMatchId", + ) + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId") + .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") + .innerJoin( + (eb) => + eb + .selectFrom("CalendarEventDate") + .select(({ fn }) => [ + "CalendarEventDate.eventId", + fn.min("CalendarEventDate.startTime").as("startTime"), + ]) + .groupBy("CalendarEventDate.eventId") + .as("EventStartTime"), + (join) => join.onRef("EventStartTime.eventId", "=", "CalendarEvent.id"), + ) + .select(({ fn }) => [ + "ReportedWeapon.weaponSplId", + fn.countAll().as("count"), + ]) + .where("ReportedWeapon.userId", "=", userId) + .where("Tournament.isFinalized", "=", 1) + .where("EventStartTime.startTime", ">=", startsTs) + .where("EventStartTime.startTime", "<=", endsTs) + .groupBy("ReportedWeapon.weaponSplId"); + + const rows = await db + .selectFrom(sendouqWeapons.unionAll(tournamentWeapons).as("merged")) + .select(({ fn }) => [ + "merged.weaponSplId", + fn.sum("merged.count").as("count"), + ]) + .groupBy("merged.weaponSplId") + .orderBy("count", "desc") + .execute(); + + return rows; +} diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 6df02cf38..31ea5d24d 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -1,22 +1,10 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { db } from "~/db/sql"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { dbInsertUsers, dbReset } from "~/utils/Test"; import * as SQGroupRepository from "../sendouq/SQGroupRepository.server"; import * as SQMatchRepository from "./SQMatchRepository.server"; -const { mockSeasonCurrentOrPrevious } = vi.hoisted(() => ({ - mockSeasonCurrentOrPrevious: vi.fn(() => ({ - nth: 1, - starts: new Date("2023-01-01"), - ends: new Date("2030-12-31"), - })), -})); - -vi.mock("~/features/mmr/core/Seasons", () => ({ - currentOrPrevious: mockSeasonCurrentOrPrevious, -})); - const createGroup = async (userIds: number[]) => { const groupResult = await SQGroupRepository.createGroup({ status: "ACTIVE", @@ -73,14 +61,6 @@ const createMatch = async (alphaGroupId: number, bravoGroupId: number) => { return match; }; -const fetchMatch = async (matchId: number) => { - return db - .selectFrom("GroupMatch") - .selectAll() - .where("id", "=", matchId) - .executeTakeFirst(); -}; - const fetchMapResults = async (matchId: number) => { return db .selectFrom("GroupMatchMap") @@ -106,87 +86,6 @@ const fetchSkills = async (matchId: number) => { .execute(); }; -const fetchReportedWeapons = async (matchId: number) => { - return db - .selectFrom("ReportedWeapon") - .innerJoin( - "GroupMatchMap", - "GroupMatchMap.id", - "ReportedWeapon.groupMatchMapId", - ) - .selectAll("ReportedWeapon") - .where("GroupMatchMap.matchId", "=", matchId) - .execute(); -}; - -describe("updateScore", () => { - beforeEach(async () => { - await dbInsertUsers(8); - }); - - afterEach(() => { - dbReset(); - }); - - test("updates match reportedAt and reportedByUserId", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.updateScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - }); - - const updatedMatch = await fetchMatch(match.id); - expect(updatedMatch?.reportedAt).not.toBeNull(); - expect(updatedMatch?.reportedByUserId).toBe(1); - }); - - test("sets winners correctly for each map", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.updateScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "BRAVO", "ALPHA", "BRAVO"], - }); - - const maps = await fetchMapResults(match.id); - expect(maps[0].winnerGroupId).toBe(alphaGroupId); - expect(maps[1].winnerGroupId).toBe(bravoGroupId); - expect(maps[2].winnerGroupId).toBe(alphaGroupId); - expect(maps[3].winnerGroupId).toBe(bravoGroupId); - expect(maps[4].winnerGroupId).toBeNull(); - }); - - test("clears previous winners before setting new ones", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.updateScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "ALPHA", "ALPHA"], - }); - - await SQMatchRepository.updateScore({ - matchId: match.id, - reportedByUserId: 5, - winners: ["BRAVO", "BRAVO", "BRAVO", "BRAVO"], - }); - - const maps = await fetchMapResults(match.id); - for (let i = 0; i < 4; i++) { - expect(maps[i].winnerGroupId).toBe(bravoGroupId); - } - }); -}); - describe("lockMatchWithoutSkillChange", () => { beforeEach(async () => { await dbInsertUsers(8); @@ -213,172 +112,6 @@ describe("lockMatchWithoutSkillChange", () => { }); }); -describe("adminReport", () => { - beforeEach(async () => { - await dbInsertUsers(8); - }); - - afterEach(() => { - dbReset(); - }); - - test("sets both groups as inactive", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.adminReport({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - }); - - const alphaGroup = await fetchGroup(alphaGroupId); - const bravoGroup = await fetchGroup(bravoGroupId); - expect(alphaGroup?.status).toBe("INACTIVE"); - expect(bravoGroup?.status).toBe("INACTIVE"); - - const updatedMatch = await fetchMatch(match.id); - expect(updatedMatch?.reportedAt).not.toBeNull(); - }); - - test("creates skills to lock the match", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.adminReport({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - }); - - const skills = await fetchSkills(match.id); - expect(skills.length).toBeGreaterThan(0); - }); -}); - -describe("reportScore", () => { - beforeEach(async () => { - await dbInsertUsers(8); - }); - - afterEach(() => { - dbReset(); - }); - - test("first report sets reporter group as inactive", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - const groupMatchMaps = await db - .selectFrom("GroupMatchMap") - .select(["id", "index"]) - .where("matchId", "=", match.id) - .orderBy("index", "asc") - .execute(); - - const result = await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [ - { - groupMatchMapId: groupMatchMaps[0].id, - weaponSplId: 40, - userId: 1, - mapIndex: 0, - }, - ], - }); - - expect(result.status).toBe("REPORTED"); - expect(result.shouldRefreshCaches).toBe(false); - - const alphaGroup = await fetchGroup(alphaGroupId); - expect(alphaGroup?.status).toBe("INACTIVE"); - - const bravoGroup = await fetchGroup(bravoGroupId); - expect(bravoGroup?.status).toBe("ACTIVE"); - - const weapons = await fetchReportedWeapons(match.id); - expect(weapons).toHaveLength(1); - }); - - test("matching second report confirms score and creates skills", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [], - }); - - const result = await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 5, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [], - }); - - expect(result.status).toBe("CONFIRMED"); - expect(result.shouldRefreshCaches).toBe(true); - - const skills = await fetchSkills(match.id); - expect(skills.length).toBeGreaterThan(0); - }); - - test("different score returns DIFFERENT status", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [], - }); - - const result = await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 5, - winners: ["BRAVO", "BRAVO", "BRAVO", "BRAVO"], - weapons: [], - }); - - expect(result.status).toBe("DIFFERENT"); - expect(result.shouldRefreshCaches).toBe(false); - }); - - test("duplicate report returns DUPLICATE status", async () => { - const alphaGroupId = await createGroup([1, 2, 3, 4]); - const bravoGroupId = await createGroup([5, 6, 7, 8]); - const match = await createMatch(alphaGroupId, bravoGroupId); - - await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [], - }); - - const result = await SQMatchRepository.reportScore({ - matchId: match.id, - reportedByUserId: 2, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [], - }); - - expect(result.status).toBe("DUPLICATE"); - expect(result.shouldRefreshCaches).toBe(false); - }); -}); - describe("cancelMatch", () => { beforeEach(async () => { await dbInsertUsers(8); @@ -438,11 +171,11 @@ describe("cancelMatch", () => { const bravoGroupId = await createGroup([5, 6, 7, 8]); const match = await createMatch(alphaGroupId, bravoGroupId); - await SQMatchRepository.reportScore({ + await SQMatchRepository.reportMapWinner({ matchId: match.id, + winnerId: alphaGroupId, reportedByUserId: 1, - winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"], - weapons: [], + reportedCount: 0, }); const result = await SQMatchRepository.cancelMatch({ @@ -453,4 +186,36 @@ describe("cancelMatch", () => { expect(result.status).toBe("CANT_CANCEL"); expect(result.shouldRefreshCaches).toBe(false); }); + + test("admin cancel locks match without applying SP changes", async () => { + const alphaGroupId = await createGroup([1, 2, 3, 4]); + const bravoGroupId = await createGroup([5, 6, 7, 8]); + const match = await createMatch(alphaGroupId, bravoGroupId); + + const adminUserId = 1; + const result = await SQMatchRepository.cancelMatch({ + matchId: match.id, + reportedByUserId: adminUserId, + isAdminReport: true, + }); + + expect(result.status).toBe("CANCEL_CONFIRMED"); + expect(result.shouldRefreshCaches).toBe(true); + + const alphaGroup = await fetchGroup(alphaGroupId); + const bravoGroup = await fetchGroup(bravoGroupId); + expect(alphaGroup?.status).toBe("INACTIVE"); + expect(bravoGroup?.status).toBe("INACTIVE"); + + const skills = await fetchSkills(match.id); + const realSkills = skills.filter((s) => s.season !== -1); + expect(realSkills).toHaveLength(0); + expect(skills).toHaveLength(1); + expect(skills[0].season).toBe(-1); + + const maps = await fetchMapResults(match.id); + for (const map of maps) { + expect(map.winnerGroupId).toBeNull(); + } + }); }); diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 8a5aa5d22..e0ca445f9 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -5,7 +5,6 @@ import * as R from "remeda"; import { db } from "~/db/sql"; import type { DB, ParsedMemento } from "~/db/tables"; import * as Seasons from "~/features/mmr/core/Seasons"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; import { mostPopularArrayElement } from "~/utils/arrays"; import { dateToDatabaseTimestamp } from "~/utils/dates"; @@ -23,14 +22,13 @@ import { SendouQError } from "../sendouq/q-utils.server"; import * as SQGroupRepository from "../sendouq/SQGroupRepository.server"; import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants"; import { compareMatchToReportedScores } from "./core/match.server"; -import { mergeReportedWeapons } from "./core/reported-weapons.server"; +import * as SendouQMatch from "./core/SendouQMatch"; import { calculateMatchSkills } from "./core/skills.server"; import { summarizeMaps, summarizePlayerResults, } from "./core/summarizer.server"; import * as PlayerStatRepository from "./PlayerStatRepository.server"; -import { winnersArrayToWinner } from "./q-match-utils"; import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server"; import * as SkillRepository from "./SkillRepository.server"; @@ -40,15 +38,24 @@ export async function findById(id: number) { .select(({ exists, selectFrom, eb }) => [ "GroupMatch.id", "GroupMatch.createdAt", - "GroupMatch.reportedAt", - "GroupMatch.reportedByUserId", + "GroupMatch.confirmedAt", + "GroupMatch.confirmedByUserId", "GroupMatch.chatCode", "GroupMatch.memento", + "GroupMatch.cancelRequestedByUserId", + "GroupMatch.cancelAcceptedByUserId", + exists( selectFrom("Skill") .select("Skill.id") .where("Skill.groupMatchId", "=", id), ).as("isLocked"), + exists( + selectFrom("Skill") + .select("Skill.id") + .where("Skill.groupMatchId", "=", id) + .where("Skill.season", "=", -1), + ).as("isCanceled"), jsonArrayFrom( eb .selectFrom("GroupMatchMap") @@ -58,6 +65,8 @@ export async function findById(id: number) { "GroupMatchMap.stageId", "GroupMatchMap.source", "GroupMatchMap.winnerGroupId", + "GroupMatchMap.reportedAt", + "GroupMatchMap.reportedByUserId", ]) .where("GroupMatchMap.matchId", "=", id) .orderBy("GroupMatchMap.index", "asc"), @@ -90,6 +99,7 @@ function groupWithTeamAndMembers( .select(({ eb }) => [ "Group.id", "Group.chatCode", + "Group.matchmade", jsonObjectFrom( eb .selectFrom("AllTeam") @@ -99,6 +109,7 @@ function groupWithTeamAndMembers( "UserSubmittedImage.id", ) .select((eb) => [ + "AllTeam.id", "AllTeam.name", "AllTeam.customUrl", concatUserSubmittedImagePrefix( @@ -112,6 +123,19 @@ function groupWithTeamAndMembers( .selectFrom("GroupMember") .innerJoin("User", "User.id", "GroupMember.userId") .leftJoin("PlusTier", "User.id", "PlusTier.userId") + .leftJoin("GroupMatchContinueVote", (join) => + join + .onRef( + "GroupMember.userId", + "=", + "GroupMatchContinueVote.userId", + ) + .onRef( + "GroupMember.groupId", + "=", + "GroupMatchContinueVote.groupId", + ), + ) .select((arrayEb) => [ ...COMMON_USER_FIELDS, "GroupMember.role", @@ -124,6 +148,7 @@ function groupWithTeamAndMembers( "User.qWeaponPool as weapons", "User.mapModePreferences", "PlusTier.tier as plusTier", + "GroupMatchContinueVote.isContinuing", arrayEb .selectFrom("UserFriendCode") .select("UserFriendCode.friendCode") @@ -233,9 +258,14 @@ const groupMatchResultsSubQuery = (eb: ExpressionBuilder) => { .selectFrom("ReportedWeapon") .select(["ReportedWeapon.userId", "ReportedWeapon.weaponSplId"]) .whereRef( - "ReportedWeapon.groupMatchMapId", + "ReportedWeapon.groupMatchId", "=", - "GroupMatchMap.id", + "GroupMatchMap.matchId", + ) + .whereRef( + "ReportedWeapon.mapIndex", + "=", + "GroupMatchMap.index", ), ).as("weapons"), ]) @@ -514,49 +544,6 @@ async function validateCreatedMatch( } } -export async function updateScore( - { - matchId, - reportedByUserId, - winners, - }: { - matchId: number; - reportedByUserId: number; - winners: ("ALPHA" | "BRAVO")[]; - }, - trx?: Transaction, -) { - const executor = trx ?? db; - - const match = await executor - .updateTable("GroupMatch") - .set({ - reportedAt: dateToDatabaseTimestamp(new Date()), - reportedByUserId, - }) - .where("id", "=", matchId) - .returningAll() - .executeTakeFirstOrThrow(); - - await executor - .updateTable("GroupMatchMap") - .set({ winnerGroupId: null }) - .where("matchId", "=", matchId) - .execute(); - - for (const [index, winner] of winners.entries()) { - await executor - .updateTable("GroupMatchMap") - .set({ - winnerGroupId: - winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId, - }) - .where("matchId", "=", matchId) - .where("index", "=", index) - .execute(); - } -} - export function lockMatchWithoutSkillChange( groupMatchId: number, trx?: Transaction, @@ -576,223 +563,49 @@ export function lockMatchWithoutSkillChange( .execute(); } -export type ReportScoreResult = - | { status: "REPORTED"; shouldRefreshCaches: false } - | { status: "CONFIRMED"; shouldRefreshCaches: true } - | { status: "DIFFERENT"; shouldRefreshCaches: false } - | { status: "DUPLICATE"; shouldRefreshCaches: false }; - export type CancelMatchResult = | { status: "CANCEL_REPORTED"; shouldRefreshCaches: false } | { status: "CANCEL_CONFIRMED"; shouldRefreshCaches: true } | { status: "CANT_CANCEL"; shouldRefreshCaches: false } | { status: "DUPLICATE"; shouldRefreshCaches: false }; -type WeaponInput = { - groupMatchMapId: number; - weaponSplId: MainWeaponId; - userId: number; - mapIndex: number; -}; - -export async function adminReport({ - matchId, - reportedByUserId, - winners, -}: { - matchId: number; - reportedByUserId: number; - winners: ("ALPHA" | "BRAVO")[]; -}): Promise { - const match = await findById(matchId); - invariant(match, "Match not found"); - - const members = buildMembers(match); - const winner = winnersArrayToWinner(winners); - const winnerGroupId = - winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id; - const loserGroupId = - winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id; - - const { newSkills, differences } = calculateMatchSkills({ - groupMatchId: match.id, - winner: (match.groupAlpha.id === winnerGroupId - ? match.groupAlpha - : match.groupBravo - ).members.map((m) => m.id), - loser: (match.groupAlpha.id === loserGroupId - ? match.groupAlpha - : match.groupBravo - ).members.map((m) => m.id), - winnerGroupId, - loserGroupId, - }); - - await db.transaction().execute(async (trx) => { - await updateScore({ matchId, reportedByUserId, winners }, trx); - await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx); - await SQGroupRepository.setAsInactive(match.groupBravo.id, trx); - await PlayerStatRepository.upsertMapResults( - summarizeMaps({ match, members, winners }), - trx, - ); - await PlayerStatRepository.upsertPlayerResults( - summarizePlayerResults({ match, members, winners }), - trx, - ); - await SkillRepository.createMatchSkills( - { - skills: newSkills, - differences, - groupMatchId: match.id, - oldMatchMemento: match.memento, - }, - trx, - ); - }); -} - -export async function reportScore({ - matchId, - reportedByUserId, - winners, - weapons, -}: { - matchId: number; - reportedByUserId: number; - winners: ("ALPHA" | "BRAVO")[]; - weapons: WeaponInput[]; -}): Promise { - const match = await findById(matchId); - invariant(match, "Match not found"); - - const members = buildMembers(match); - const reporterGroupId = members.find( - (m) => m.id === reportedByUserId, - )?.groupId; - invariant(reporterGroupId, "Reporter is not a member of any group"); - - const previousReporterGroupId = match.reportedByUserId - ? members.find((m) => m.id === match.reportedByUserId)?.groupId - : undefined; - - const compared = compareMatchToReportedScores({ - match, - winners, - newReporterGroupId: reporterGroupId, - previousReporterGroupId, - }); - - const oldReportedWeapons = - (await ReportedWeaponRepository.findByMatchId(matchId)) ?? []; - const mergedWeapons = mergeReportedWeapons({ - oldWeapons: oldReportedWeapons, - newWeapons: weapons, - newReportedMapsCount: winners.length, - }); - const weaponsForDb = mergedWeapons.map((w) => ({ - groupMatchMapId: w.groupMatchMapId, - userId: w.userId, - weaponSplId: w.weaponSplId, - })); - - if (compared === "DUPLICATE") { - await ReportedWeaponRepository.replaceByMatchId(matchId, weaponsForDb); - return { status: "DUPLICATE", shouldRefreshCaches: false }; - } - - if (compared === "DIFFERENT") { - await SQGroupRepository.setAsInactive(reporterGroupId); - return { status: "DIFFERENT", shouldRefreshCaches: false }; - } - - if (compared === "FIRST_REPORT") { - await db.transaction().execute(async (trx) => { - await updateScore({ matchId, reportedByUserId, winners }, trx); - await SQGroupRepository.setAsInactive(reporterGroupId, trx); - if (weaponsForDb.length > 0) { - await ReportedWeaponRepository.createMany(weaponsForDb, trx); - } - }); - return { status: "REPORTED", shouldRefreshCaches: false }; - } - - if (compared === "FIX_PREVIOUS") { - await db.transaction().execute(async (trx) => { - await updateScore({ matchId, reportedByUserId, winners }, trx); - await ReportedWeaponRepository.replaceByMatchId( - matchId, - weaponsForDb, - trx, - ); - }); - return { status: "REPORTED", shouldRefreshCaches: false }; - } - - const winner = winnersArrayToWinner(winners); - const winnerGroupId = - winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id; - const loserGroupId = - winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id; - - const { newSkills, differences } = calculateMatchSkills({ - groupMatchId: match.id, - winner: (match.groupAlpha.id === winnerGroupId - ? match.groupAlpha - : match.groupBravo - ).members.map((m) => m.id), - loser: (match.groupAlpha.id === loserGroupId - ? match.groupAlpha - : match.groupBravo - ).members.map((m) => m.id), - winnerGroupId, - loserGroupId, - }); - - await db.transaction().execute(async (trx) => { - await SQGroupRepository.setAsInactive(reporterGroupId, trx); - await PlayerStatRepository.upsertMapResults( - summarizeMaps({ match, members, winners }), - trx, - ); - await PlayerStatRepository.upsertPlayerResults( - summarizePlayerResults({ match, members, winners }), - trx, - ); - await SkillRepository.createMatchSkills( - { - skills: newSkills, - differences, - groupMatchId: match.id, - oldMatchMemento: match.memento, - }, - trx, - ); - await ReportedWeaponRepository.replaceByMatchId(matchId, weaponsForDb, trx); - }); - - return { status: "CONFIRMED", shouldRefreshCaches: true }; -} - export async function cancelMatch({ matchId, reportedByUserId, + isAdminReport, }: { matchId: number; reportedByUserId: number; + isAdminReport?: boolean; }): Promise { const match = await findById(matchId); invariant(match, "Match not found"); + if (isAdminReport) { + await db.transaction().execute(async (trx) => { + await trx + .updateTable("GroupMatchMap") + .set({ + winnerGroupId: null, + reportedAt: dateToDatabaseTimestamp(new Date()), + reportedByUserId, + }) + .where("matchId", "=", matchId) + .execute(); + await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx); + await SQGroupRepository.setAsInactive(match.groupBravo.id, trx); + await lockMatchWithoutSkillChange(match.id, trx); + }); + return { status: "CANCEL_CONFIRMED", shouldRefreshCaches: true }; + } + const members = buildMembers(match); const reporterGroupId = members.find( (m) => m.id === reportedByUserId, )?.groupId; invariant(reporterGroupId, "Reporter is not a member of any group"); - const previousReporterGroupId = match.reportedByUserId - ? members.find((m) => m.id === match.reportedByUserId)?.groupId - : undefined; + const previousReporterGroupId = lastReporterGroupId(match, members); const compared = compareMatchToReportedScores({ match, @@ -812,7 +625,15 @@ export async function cancelMatch({ if (compared === "FIRST_REPORT" || compared === "FIX_PREVIOUS") { await db.transaction().execute(async (trx) => { - await updateScore({ matchId, reportedByUserId, winners: [] }, trx); + await trx + .updateTable("GroupMatchMap") + .set({ + winnerGroupId: null, + reportedAt: dateToDatabaseTimestamp(new Date()), + reportedByUserId, + }) + .where("matchId", "=", matchId) + .execute(); await SQGroupRepository.setAsInactive(reporterGroupId, trx); if (compared === "FIX_PREVIOUS") { await ReportedWeaponRepository.replaceByMatchId(matchId, [], trx); @@ -828,6 +649,550 @@ export async function cancelMatch({ return { status: "CANCEL_CONFIRMED", shouldRefreshCaches: true }; } +export type RequestCancelResult = + | { status: "REQUESTED" } + | { status: "ALREADY_LOCKED" } + | { status: "ALREADY_REQUESTED" }; + +export async function requestCancelMatch({ + matchId, + requestedByUserId, +}: { + matchId: number; + requestedByUserId: number; +}): Promise { + const match = await findById(matchId); + invariant(match, "Match not found"); + + if (match.isLocked) { + return { status: "ALREADY_LOCKED" }; + } + + if (match.cancelRequestedByUserId) { + return { status: "ALREADY_REQUESTED" }; + } + + await db + .updateTable("GroupMatch") + .set({ cancelRequestedByUserId: requestedByUserId }) + .where("id", "=", matchId) + .execute(); + + return { status: "REQUESTED" }; +} + +export type AcceptCancelResult = + | { status: "ACCEPTED" } + | { status: "ALREADY_LOCKED" } + | { status: "NO_CANCEL_REQUEST" } + | { status: "NOT_ALLOWED" }; + +export async function acceptCancelMatch({ + matchId, + acceptedByUserId, +}: { + matchId: number; + acceptedByUserId: number; +}): Promise { + const match = await findById(matchId); + invariant(match, "Match not found"); + + if (match.isLocked) { + return { status: "ALREADY_LOCKED" }; + } + + if (!match.cancelRequestedByUserId) { + return { status: "NO_CANCEL_REQUEST" }; + } + + const members = buildMembers(match); + const requesterGroupId = members.find( + (m) => m.id === match.cancelRequestedByUserId, + )?.groupId; + invariant(requesterGroupId, "Requester is not a member of any group"); + + const accepterGroupId = members.find( + (m) => m.id === acceptedByUserId, + )?.groupId; + invariant(accepterGroupId, "Accepter is not a member of any group"); + + if (accepterGroupId === requesterGroupId) { + return { status: "NOT_ALLOWED" }; + } + + await db.transaction().execute(async (trx) => { + await SQGroupRepository.setAsInactive(requesterGroupId, trx); + await SQGroupRepository.setAsInactive(accepterGroupId, trx); + await lockMatchWithoutSkillChange(match.id, trx); + await trx + .updateTable("GroupMatch") + .set({ cancelAcceptedByUserId: acceptedByUserId }) + .where("id", "=", matchId) + .execute(); + }); + + return { status: "ACCEPTED" }; +} + +export type RefuseCancelResult = + | { status: "REFUSED" } + | { status: "ALREADY_LOCKED" } + | { status: "NO_CANCEL_REQUEST" } + | { status: "NOT_ALLOWED" }; + +export async function refuseCancelMatch({ + matchId, + refusedByUserId, +}: { + matchId: number; + refusedByUserId: number; +}): Promise { + const match = await findById(matchId); + invariant(match, "Match not found"); + + if (match.isLocked) { + return { status: "ALREADY_LOCKED" }; + } + + if (!match.cancelRequestedByUserId) { + return { status: "NO_CANCEL_REQUEST" }; + } + + const members = buildMembers(match); + const requesterGroupId = members.find( + (m) => m.id === match.cancelRequestedByUserId, + )?.groupId; + const refuserGroupId = members.find((m) => m.id === refusedByUserId)?.groupId; + invariant(refuserGroupId, "Refuser is not a member of any group"); + + if (refuserGroupId === requesterGroupId) { + return { status: "NOT_ALLOWED" }; + } + + await db + .updateTable("GroupMatch") + .set({ cancelRequestedByUserId: null }) + .where("id", "=", matchId) + .execute(); + + return { status: "REFUSED" }; +} + +export type ReportMapWinnerResult = + | { status: "MAP_REPORTED" } + | { status: "MATCH_REPORTED" } + | { status: "MATCH_FINALIZED" } + | { status: "ALREADY_LOCKED" } + | { status: "INVALID_WINNER" } + | { status: "SCORE_DISAGREEMENT" } + | { status: "STALE" }; + +export async function reportMapWinner({ + matchId, + winnerId, + reportedByUserId, + reportedCount, + isStaffReport, +}: { + matchId: number; + winnerId: number; + reportedByUserId: number; + reportedCount: number; + isStaffReport?: boolean; +}): Promise { + const match = await findById(matchId); + invariant(match, "Match not found"); + + if (match.isLocked) { + return { status: "ALREADY_LOCKED" }; + } + + if (winnerId !== match.groupAlpha.id && winnerId !== match.groupBravo.id) { + return { status: "INVALID_WINNER" }; + } + + const { + mapsToWin, + alphaWins: existingAlphaWins, + bravoWins: existingBravoWins, + isDecisive: scoreAlreadyDecisive, + } = SendouQMatch.score(match); + + // Confirmation flow: score is already decisive (first team reported the set-ending map) + if (scoreAlreadyDecisive) { + // Staff sees the Undo view in awaiting state and cannot reach this path via the UI + if (isStaffReport) return { status: "STALE" }; + return handleMatchConfirmation({ + match, + winnerId, + reportedByUserId, + existingAlphaWins, + mapsToWin, + }); + } + + const actualReportedCount = match.mapList.filter( + (m) => m.winnerGroupId !== null, + ).length; + if (actualReportedCount !== reportedCount) { + return { status: "STALE" }; + } + + const currentMap = match.mapList.find((m) => m.winnerGroupId === null); + invariant(currentMap, "No unreported map found"); + + const alphaWins = + existingAlphaWins + (winnerId === match.groupAlpha.id ? 1 : 0); + const bravoWins = + existingBravoWins + (winnerId === match.groupBravo.id ? 1 : 0); + const matchIsOver = alphaWins >= mapsToWin || bravoWins >= mapsToWin; + + // Non-final map: report and continue + if (!matchIsOver) { + await db + .updateTable("GroupMatchMap") + .set({ + winnerGroupId: winnerId, + reportedAt: dateToDatabaseTimestamp(new Date()), + reportedByUserId, + }) + .where("id", "=", currentMap.id) + .execute(); + return { status: "MAP_REPORTED" }; + } + + // Set-ending map reported by staff: auto-finalize (no awaiting confirmation) + if (isStaffReport) { + return handleStaffFinalization({ + match, + currentMap, + winnerId, + reportedByUserId, + }); + } + + // Set-ending map: first report, await confirmation from other team + const members = buildMembers(match); + const reporterGroupId = members.find( + (m) => m.id === reportedByUserId, + )?.groupId; + invariant(reporterGroupId, "Reporter is not a member of any group"); + + await db.transaction().execute(async (trx) => { + await trx + .updateTable("GroupMatchMap") + .set({ + winnerGroupId: winnerId, + reportedAt: dateToDatabaseTimestamp(new Date()), + reportedByUserId, + }) + .where("id", "=", currentMap.id) + .execute(); + await SQGroupRepository.setAsInactive(reporterGroupId, trx); + }); + + return { status: "MATCH_REPORTED" }; +} + +async function handleMatchConfirmation({ + match, + winnerId, + reportedByUserId, + existingAlphaWins, + mapsToWin, +}: { + match: NonNullable>>; + winnerId: number; + reportedByUserId: number; + existingAlphaWins: number; + mapsToWin: number; +}): Promise { + const members = buildMembers(match); + const reporterGroupId = members.find( + (m) => m.id === reportedByUserId, + )?.groupId; + invariant(reporterGroupId, "Reporter is not a member of any group"); + + // Find the deciding map (last map with a winner) + const decidingMap = match.mapList + .toReversed() + .find((m) => m.winnerGroupId !== null); + invariant(decidingMap, "No deciding map found"); + + const originalReporterGroupId = decidingMap.reportedByUserId + ? members.find((m) => m.id === decidingMap.reportedByUserId)?.groupId + : undefined; + + // Same team re-reporting + if (reporterGroupId === originalReporterGroupId) { + return { status: "STALE" }; + } + + // Other team reports a different winner for the deciding map + if (winnerId !== decidingMap.winnerGroupId) { + await SQGroupRepository.setAsInactive(reporterGroupId); + return { status: "SCORE_DISAGREEMENT" }; + } + + // Other team confirms the score — finalize + const winnerGroupId = + existingAlphaWins >= mapsToWin ? match.groupAlpha.id : match.groupBravo.id; + const loserGroupId = + existingAlphaWins >= mapsToWin ? match.groupBravo.id : match.groupAlpha.id; + + const winners: ("ALPHA" | "BRAVO")[] = match.mapList + .filter((m) => m.winnerGroupId !== null) + .map((m) => (m.winnerGroupId === match.groupAlpha.id ? "ALPHA" : "BRAVO")); + + await finalizeMatch({ + match, + members, + winners, + winnerGroupId, + loserGroupId, + confirmedByUserId: reportedByUserId, + preFinalize: (trx) => SQGroupRepository.setAsInactive(reporterGroupId, trx), + }); + + return { status: "MATCH_FINALIZED" }; +} + +async function handleStaffFinalization({ + match, + currentMap, + winnerId, + reportedByUserId, +}: { + match: NonNullable>>; + currentMap: NonNullable< + Awaited> + >["mapList"][number]; + winnerId: number; + reportedByUserId: number; +}): Promise { + const winnerGroupId = winnerId; + const loserGroupId = + winnerId === match.groupAlpha.id + ? match.groupBravo.id + : match.groupAlpha.id; + + const members = buildMembers(match); + + const winners: ("ALPHA" | "BRAVO")[] = [ + ...match.mapList + .filter((m) => m.winnerGroupId !== null) + .map((m) => + m.winnerGroupId === match.groupAlpha.id + ? ("ALPHA" as const) + : ("BRAVO" as const), + ), + winnerId === match.groupAlpha.id ? "ALPHA" : "BRAVO", + ]; + + await finalizeMatch({ + match, + members, + winners, + winnerGroupId, + loserGroupId, + confirmedByUserId: reportedByUserId, + preFinalize: async (trx) => { + await trx + .updateTable("GroupMatchMap") + .set({ + winnerGroupId, + reportedAt: dateToDatabaseTimestamp(new Date()), + reportedByUserId, + }) + .where("id", "=", currentMap.id) + .execute(); + await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx); + await SQGroupRepository.setAsInactive(match.groupBravo.id, trx); + }, + }); + + return { status: "MATCH_FINALIZED" }; +} + +async function finalizeMatch({ + match, + members, + winners, + winnerGroupId, + loserGroupId, + confirmedByUserId, + preFinalize, +}: { + match: NonNullable>>; + members: ReturnType; + winners: ("ALPHA" | "BRAVO")[]; + winnerGroupId: number; + loserGroupId: number; + confirmedByUserId: number; + preFinalize?: (trx: Transaction) => Promise; +}) { + const { newSkills, differences } = calculateMatchSkills({ + groupMatchId: match.id, + winner: (match.groupAlpha.id === winnerGroupId + ? match.groupAlpha + : match.groupBravo + ).members.map((m) => m.id), + loser: (match.groupAlpha.id === loserGroupId + ? match.groupAlpha + : match.groupBravo + ).members.map((m) => m.id), + winnerGroupId, + loserGroupId, + }); + + await db.transaction().execute(async (trx) => { + if (preFinalize) await preFinalize(trx); + await trx + .updateTable("GroupMatch") + .set({ + confirmedAt: dateToDatabaseTimestamp(new Date()), + confirmedByUserId, + }) + .where("id", "=", match.id) + .execute(); + await PlayerStatRepository.upsertMapResults( + summarizeMaps({ match, members, winners }), + trx, + ); + await PlayerStatRepository.upsertPlayerResults( + summarizePlayerResults({ match, members, winners }), + trx, + ); + await SkillRepository.createMatchSkills( + { + skills: newSkills, + differences, + groupMatchId: match.id, + oldMatchMemento: match.memento, + }, + trx, + ); + }); +} + +export async function undoMatchReport({ + matchId, + requestedByUserId, + isStaff, +}: { + matchId: number; + requestedByUserId: number; + isStaff?: boolean; +}): Promise<{ status: "SUCCESS" | "NOT_ALLOWED" | "ALREADY_LOCKED" }> { + const match = await findById(matchId); + invariant(match, "Match not found"); + + if (match.isLocked) { + return { status: "ALREADY_LOCKED" }; + } + + if (!SendouQMatch.score(match).isDecisive) { + return { status: "NOT_ALLOWED" }; + } + + const decidingMapIndex = match.mapList.findLastIndex( + (m) => m.winnerGroupId !== null, + ); + const decidingMap = + decidingMapIndex === -1 ? undefined : match.mapList[decidingMapIndex]; + invariant(decidingMap, "No deciding map found"); + + if (!decidingMap.reportedByUserId) { + return { status: "NOT_ALLOWED" }; + } + + const members = buildMembers(match); + const requesterGroupId = members.find( + (m) => m.id === requestedByUserId, + )?.groupId; + const reporterGroupId = members.find( + (m) => m.id === decidingMap.reportedByUserId, + )?.groupId; + + if (!isStaff && requesterGroupId !== reporterGroupId) { + return { status: "NOT_ALLOWED" }; + } + + await db.transaction().execute(async (trx) => { + await trx + .updateTable("GroupMatchMap") + .set({ winnerGroupId: null, reportedAt: null, reportedByUserId: null }) + .where("id", "=", decidingMap.id) + .execute(); + + await ReportedWeaponRepository.deleteByMapIndex( + { matchId, mapIndex: decidingMapIndex }, + trx, + ); + + await trx + .deleteFrom("GroupMatchContinueVote") + .where("GroupMatchContinueVote.groupId", "in", [ + match.groupAlpha.id, + match.groupBravo.id, + ]) + .execute(); + }); + + return { status: "SUCCESS" }; +} + +export async function undoMapReport({ + matchId, + mapIndex, +}: { + matchId: number; + mapIndex: number; +}): Promise<{ status: "SUCCESS" | "NOT_ALLOWED" | "ALREADY_LOCKED" }> { + const match = await findById(matchId); + invariant(match, "Match not found"); + + if (match.isLocked) { + return { status: "ALREADY_LOCKED" }; + } + + if (SendouQMatch.score(match).isDecisive) { + return { status: "NOT_ALLOWED" }; + } + + const targetMap = match.mapList[mapIndex]; + if (!targetMap || targetMap.winnerGroupId === null) { + return { status: "NOT_ALLOWED" }; + } + + const hasLaterReport = match.mapList + .slice(mapIndex + 1) + .some((m) => m.winnerGroupId !== null); + if (hasLaterReport) { + return { status: "NOT_ALLOWED" }; + } + + await db.transaction().execute(async (trx) => { + await trx + .updateTable("GroupMatchMap") + .set({ winnerGroupId: null }) + .where("id", "=", targetMap.id) + .execute(); + + await ReportedWeaponRepository.deleteByMapIndex({ matchId, mapIndex }, trx); + + await trx + .deleteFrom("GroupMatchContinueVote") + .where("GroupMatchContinueVote.groupId", "in", [ + match.groupAlpha.id, + match.groupBravo.id, + ]) + .execute(); + }); + + return { status: "SUCCESS" }; +} + function buildMembers( match: NonNullable>>, ) { @@ -842,3 +1207,15 @@ function buildMembers( })), ]; } + +function lastReporterGroupId( + match: NonNullable>>, + members: ReturnType, +) { + const lastReportedMap = match.mapList + .toReversed() + .find((m) => m.reportedByUserId !== null); + if (!lastReportedMap?.reportedByUserId) return undefined; + return members.find((m) => m.id === lastReportedMap.reportedByUserId) + ?.groupId; +} diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index e1e7960c2..69d4fa7b4 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -1,9 +1,8 @@ import type { ActionFunctionArgs } from "react-router"; import { redirect } from "react-router"; -import type { ReportedWeapon } from "~/db/tables"; +import { db } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; -import type { ChatMessage } from "~/features/chat/chat-types"; import * as Seasons from "~/features/mmr/core/Seasons"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; import { @@ -11,21 +10,24 @@ import { SendouQ, } from "~/features/sendouq/core/SendouQ.server"; import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; +import { SendouQError } from "~/features/sendouq/q-utils.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server"; -import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { + errorToast, errorToastIfFalsy, notFoundIfFalsy, parseParams, parseRequestPayload, } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { SENDOUQ_PREPARING_PAGE, sendouQMatchPage } from "~/utils/urls"; -import { mergeReportedWeapons } from "../core/reported-weapons.server"; +import { sendouQMatchPage } from "~/utils/urls"; +import * as RejoinVote from "../core/RejoinVote"; +import * as SendouQMatch from "../core/SendouQMatch"; import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas"; export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -39,85 +41,332 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { schema: matchSchema, }); - switch (data._action) { - case "REPORT_SCORE": { - const unmappedMatch = notFoundIfFalsy( - await SQMatchRepository.findById(matchId), - ); - const match = SendouQ.mapMatch(unmappedMatch, user); + const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); + const isStaff = user.roles.includes("STAFF"); + const isParticipant = [ + ...match.groupAlpha.members, + ...match.groupBravo.members, + ].some((m) => m.id === user.id); + errorToastIfFalsy( + isParticipant || isStaff, + "Not a participant of this match", + ); - if (match.isLocked) { - const oldReportedWeapons = - (await ReportedWeaponRepository.findByMatchId(matchId)) ?? []; - const mergedWeapons = mergeReportedWeapons({ - oldWeapons: oldReportedWeapons, - newWeapons: data.weapons, - newReportedMapsCount: data.winners.length, - }); - await ReportedWeaponRepository.replaceByMatchId( - matchId, - mergedWeapons.map((w) => ({ - groupMatchMapId: w.groupMatchMapId, - userId: w.userId, - weaponSplId: w.weaponSplId, - })), - ); - return null; - } - - errorToastIfFalsy( - !data.adminReport || user.roles.includes("STAFF"), - "Only mods can report scores as admin", - ); - - const members = [ - ...match.groupAlpha.members.map((m) => ({ - ...m, - groupId: match.groupAlpha.id, - })), - ...match.groupBravo.members.map((m) => ({ - ...m, - groupId: match.groupBravo.id, - })), - ]; - invariant( - members.some((m) => m.id === user.id) || data.adminReport, - "User is not a member of any group", - ); - - if (data.adminReport) { - await SQMatchRepository.adminReport({ + try { + switch (data._action) { + case "REPORT_SCORE": { + const isStaffReport = !isParticipant && isStaff; + + const result = await SQMatchRepository.reportMapWinner({ matchId, + winnerId: data.winnerId, reportedByUserId: user.id, - winners: data.winners, + reportedCount: data.reportedCount, + isStaffReport, }); - try { - refreshUserSkills(Seasons.currentOrPrevious()!.nth); - } catch (error) { - logger.warn("Error refreshing user skills", error); + if (result.status === "ALREADY_LOCKED" || result.status === "STALE") { + return null; } - refreshStreamsCache(); + + if (result.status === "INVALID_WINNER") { + return errorToast("Invalid winner id"); + } + + if (result.status === "SCORE_DISAGREEMENT") { + await refreshSendouQInstance(); + return errorToast( + "Score does not match the other team's report. Contact the other team to adjust.", + ); + } + + if (result.status === "MATCH_FINALIZED") { + try { + refreshUserSkills(Seasons.currentOrPrevious()!.nth); + } catch (error) { + logger.warn("Error refreshing user skills", error); + } + refreshStreamsCache(); + } + + await refreshSendouQInstance(); + + if (match.chatCode) { + if (result.status === "MATCH_FINALIZED") { + ChatSystemMessage.send({ + room: match.chatCode, + type: "SCORE_CONFIRMED", + context: { name: user.username }, + }); + } else { + ChatSystemMessage.send({ + room: match.chatCode, + revalidateOnly: true, + }); + } + } + + break; + } + case "LOOK_AGAIN": { + const season = Seasons.current(); + errorToastIfFalsy(season, "Season is not active"); + + const previousGroup = + match.groupAlpha.id === data.previousGroupId + ? match.groupAlpha + : match.groupBravo.id === data.previousGroupId + ? match.groupBravo + : null; + errorToastIfFalsy( + previousGroup, + "Previous group not found in this match", + ); + + errorToastIfFalsy( + !previousGroup.matchmade, + "This group must use the continue vote", + ); + + const requester = previousGroup.members.find((m) => m.id === user.id); + errorToastIfFalsy( + requester?.role === "OWNER", + "You are not the owner of the group", + ); + + for (const member of previousGroup.members) { + const currentGroup = SendouQ.findOwnGroup(member.id); + errorToastIfFalsy(!currentGroup, "Member is already in a group"); + } + + await SQGroupRepository.createGroupFromPrevious({ + previousGroupId: data.previousGroupId, + members: previousGroup.members.map((m) => ({ + id: m.id, + role: m.role, + })), + status: "ACTIVE", + }); await refreshSendouQInstance(); if (match.chatCode) { ChatSystemMessage.send({ room: match.chatCode, - type: "SCORE_CONFIRMED", - context: { name: user.username }, + revalidateOnly: true, }); } break; } + case "CAST_CONTINUE_VOTE": { + const viewerSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: user.id, + }); + errorToastIfFalsy(viewerSide, "Not a participant"); - const matchIsBeingCanceled = data.winners.length === 0; + const viewerGroup = + viewerSide === "ALPHA" ? match.groupAlpha : match.groupBravo; + errorToastIfFalsy( + viewerGroup.matchmade, + "This group uses the trusted rematch flow", + ); + + const votingResult = await db.transaction().execute(async (trx) => { + const existingVotes = + await GroupMatchContinueVoteRepository.findForGroups( + [viewerGroup.id], + trx, + ); + + if (!RejoinVote.canCastVote(existingVotes, user.id)) { + return null; + } + + await GroupMatchContinueVoteRepository.cast( + { + groupId: viewerGroup.id, + userId: user.id, + isContinuing: data.isContinuing, + }, + trx, + ); + + return RejoinVote.result( + await GroupMatchContinueVoteRepository.findForGroups( + [viewerGroup.id], + trx, + ), + ); + }); + + if (votingResult?.type === "RESOLVED") { + const survivors = viewerGroup.members + .filter((m) => votingResult.continuingUserIds.includes(m.id)) + .map((m) => ({ id: m.id, role: m.role })); + + try { + await SQGroupRepository.createGroupFromPrevious({ + previousGroupId: viewerGroup.id, + members: survivors, + status: "ACTIVE", + }); + } catch (error) { + // a concurrent voter may have already created the successor + // group; the in-memory queue still needs to be refreshed below + if (!(error instanceof SendouQError)) throw error; + } + + await refreshSendouQInstance(); + } + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + revalidateOnly: true, + }); + } + + break; + } + case "REPORT_WEAPON": { + await ReportedWeaponRepository.upsertOne({ + groupMatchId: matchId, + mapIndex: data.mapIndex, + userId: user.id, + weaponSplId: data.weaponSplId, + }); + + break; + } + case "UNDO_WEAPON_REPORT": { + await ReportedWeaponRepository.deleteByUserMapIndex({ + matchId, + userId: user.id, + mapIndex: data.mapIndex, + }); + + break; + } + case "ADD_PRIVATE_USER_NOTE": { + await PrivateUserNoteRepository.upsert({ + authorId: user.id, + sentiment: data.sentiment, + targetId: data.targetId, + text: data.comment, + }); + + throw redirect(sendouQMatchPage(matchId)); + } + case "UNDO_MATCH_REPORT": { + const result = await SQMatchRepository.undoMatchReport({ + matchId, + requestedByUserId: user.id, + isStaff, + }); + + if (result.status === "NOT_ALLOWED") { + return errorToast("Cannot undo report"); + } + if (result.status === "ALREADY_LOCKED") { + return null; + } + + await refreshSendouQInstance(); + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + revalidateOnly: true, + }); + } + + break; + } + case "UNDO_MAP_REPORT": { + const result = await SQMatchRepository.undoMapReport({ + matchId, + mapIndex: data.mapIndex, + }); + + if (result.status === "NOT_ALLOWED") { + return errorToast("Cannot undo map report"); + } + if (result.status === "ALREADY_LOCKED") { + return null; + } + + await refreshSendouQInstance(); + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + revalidateOnly: true, + }); + } + + break; + } + case "REQUEST_CANCEL": { + const result = await SQMatchRepository.requestCancelMatch({ + matchId, + requestedByUserId: user.id, + }); + + if (result.status === "ALREADY_LOCKED") { + return null; + } + if (result.status === "ALREADY_REQUESTED") { + return null; + } + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + type: "CANCEL_REPORTED", + context: { name: user.username }, + }); + } + + await refreshSendouQInstance(); + break; + } + case "ACCEPT_CANCEL": { + const result = await SQMatchRepository.acceptCancelMatch({ + matchId, + acceptedByUserId: user.id, + }); + + if (result.status === "ALREADY_LOCKED") { + return null; + } + if (result.status === "NO_CANCEL_REQUEST") { + return null; + } + if (result.status === "NOT_ALLOWED") { + return errorToast("Cannot accept own cancel request"); + } + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + type: "CANCEL_CONFIRMED", + context: { name: user.username }, + }); + } + + await refreshSendouQInstance(); + break; + } + case "ADMIN_CANCEL": { + errorToastIfFalsy(isStaff, "Only mods can admin cancel"); - if (matchIsBeingCanceled) { const result = await SQMatchRepository.cancelMatch({ matchId, reportedByUserId: user.id, + isAdminReport: true, }); if (result.shouldRefreshCaches) { @@ -129,147 +378,57 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { refreshStreamsCache(); } - if (result.status === "CANT_CANCEL") { - return { error: "cant-cancel" as const }; - } - - if (result.status === "DUPLICATE") { - break; - } - await refreshSendouQInstance(); if (match.chatCode) { - const type: NonNullable = - result.status === "CANCEL_CONFIRMED" - ? "CANCEL_CONFIRMED" - : "CANCEL_REPORTED"; - ChatSystemMessage.send({ room: match.chatCode, - type, - context: { name: user.username }, + revalidateOnly: true, }); } break; } - - const result = await SQMatchRepository.reportScore({ - matchId, - reportedByUserId: user.id, - winners: data.winners, - weapons: data.weapons as (ReportedWeapon & { - mapIndex: number; - groupMatchMapId: number; - })[], - }); - - if (result.shouldRefreshCaches) { - try { - refreshUserSkills(Seasons.currentOrPrevious()!.nth); - } catch (error) { - logger.warn("Error refreshing user skills", error); - } - refreshStreamsCache(); - } - - if (result.status === "DIFFERENT") { - return { error: "different" as const }; - } - - if (result.status !== "DUPLICATE") { - await refreshSendouQInstance(); - } - - if (match.chatCode && result.status !== "DUPLICATE") { - const type: NonNullable = - result.status === "CONFIRMED" ? "SCORE_CONFIRMED" : "SCORE_REPORTED"; - - ChatSystemMessage.send({ - room: match.chatCode, - type, - context: { name: user.username }, + case "REFUSE_CANCEL": { + const result = await SQMatchRepository.refuseCancelMatch({ + matchId, + refusedByUserId: user.id, }); - } - break; - } - case "LOOK_AGAIN": { - const season = Seasons.current(); - errorToastIfFalsy(season, "Season is not active"); - - const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); - const previousGroup = - match.groupAlpha.id === data.previousGroupId - ? match.groupAlpha - : match.groupBravo.id === data.previousGroupId - ? match.groupBravo - : null; - errorToastIfFalsy( - previousGroup, - "Previous group not found in this match", - ); - - for (const member of previousGroup.members) { - const currentGroup = SendouQ.findOwnGroup(member.id); - errorToastIfFalsy(!currentGroup, "Member is already in a group"); - if (member.id === user.id) { - errorToastIfFalsy( - member.role === "OWNER", - "You are not the owner of the group", - ); + if (result.status === "ALREADY_LOCKED") { + return null; } + if (result.status === "NO_CANCEL_REQUEST") { + return null; + } + if (result.status === "NOT_ALLOWED") { + return errorToast("Cannot refuse own cancel request"); + } + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + type: "CANCEL_REFUSED", + context: { name: user.username }, + }); + } + + await refreshSendouQInstance(); + break; + } + default: { + assertUnreachable(data); } - - await SQGroupRepository.createGroupFromPrevious({ - previousGroupId: data.previousGroupId, - members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })), - }); - - await refreshSendouQInstance(); - - throw redirect(SENDOUQ_PREPARING_PAGE); } - case "REPORT_WEAPONS": { - const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); - errorToastIfFalsy(match.reportedAt, "Match has not been reported yet"); - - const oldReportedWeapons = - (await ReportedWeaponRepository.findByMatchId(matchId)) ?? []; - - const mergedWeapons = mergeReportedWeapons({ - oldWeapons: oldReportedWeapons, - newWeapons: data.weapons as (ReportedWeapon & { - mapIndex: number; - groupMatchMapId: number; - })[], - }); - - await ReportedWeaponRepository.replaceByMatchId( - matchId, - mergedWeapons.map((w) => ({ - groupMatchMapId: w.groupMatchMapId, - userId: w.userId, - weaponSplId: w.weaponSplId, - })), - ); - - break; + } catch (error) { + // some errors are expected to happen, for example two requests racing to + // create/join a group. return null so loaders re-run and the user sees + // the fresh state instead of an error page + if (error instanceof SendouQError) { + return null; } - case "ADD_PRIVATE_USER_NOTE": { - await PrivateUserNoteRepository.upsert({ - authorId: user.id, - sentiment: data.sentiment, - targetId: data.targetId, - text: data.comment, - }); - throw redirect(sendouQMatchPage(matchId)); - } - default: { - assertUnreachable(data); - } + throw error; } return null; diff --git a/app/features/sendouq-match/components/RejoinSections.tsx b/app/features/sendouq-match/components/RejoinSections.tsx new file mode 100644 index 000000000..7c664cc0b --- /dev/null +++ b/app/features/sendouq-match/components/RejoinSections.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { Link, useFetcher } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SENDOUQ_PAGE } from "~/utils/urls"; +import * as RejoinVote from "../core/RejoinVote"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; +import { RematchVotePanel } from "./RematchVotePanel"; + +export function MatchmadeRejoinSection({ + data, + viewerGroup, + viewerUserId, + awaitingConfirmation, + isOnReporterTeam, +}: { + data: SendouQMatchLoaderData; + viewerGroup: NonNullable; + viewerUserId: number; + awaitingConfirmation: boolean; + isOnReporterTeam: boolean; +}) { + const voteFetcher = useFetcher(); + + const votes = RejoinVote.extractOwnGroupVotesFromSendouqMatch( + data.match, + viewerUserId, + ); + + if (!votes) return null; + + if (RejoinVote.userContinueStatus(votes, viewerUserId) === false) { + return ; + } + + // During awaiting confirmation, only reporter team can cascade. + if (awaitingConfirmation && !isOnReporterTeam) return null; + + return ( + ({ + id: m.id, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + }))} + votes={votes} + viewerUserId={viewerUserId} + fetcher={voteFetcher} + /> + ); +} + +export function TrustedRejoinSection({ + viewerGroup, + viewerUserId, +}: { + viewerGroup: NonNullable; + viewerUserId: number; +}) { + const { t } = useTranslation(["q"]); + const viewerRole = viewerGroup.members.find( + (m) => m.id === viewerUserId, + )?.role; + const lookAgainFetcher = useFetcher(); + + if (viewerRole === "OWNER") { + return ( +
+ { + lookAgainFetcher.submit( + { + _action: "LOOK_AGAIN", + previousGroupId: String(viewerGroup.id), + }, + { method: "post" }, + ); + }} + > + {t("q:match.actions.lookAgain")} + +
+ ); + } + + return ( +

+ {t("q:match.rematch.waitingCaptain")} +

+ ); +} + +function DeclinedSection() { + const { t } = useTranslation(["q"]); + return ( +
+

+ {t("q:match.rematch.declined")} +

+ + {t("q:match.rematch.rejoinQueue")} + +
+ ); +} diff --git a/app/features/sendouq-match/components/RematchVotePanel.module.css b/app/features/sendouq-match/components/RematchVotePanel.module.css new file mode 100644 index 000000000..5c57ed62a --- /dev/null +++ b/app/features/sendouq-match/components/RematchVotePanel.module.css @@ -0,0 +1,60 @@ +.root { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--s-4); +} + +.prompt { + font-size: var(--font-md); + font-weight: var(--weight-semi); + text-align: center; +} + +.list { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--s-2); + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--s-3); + padding: var(--s-1) var(--s-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background-color: var(--color-bg-higher); + width: 20rem; + margin: 0 auto; +} + +.username { + font-weight: var(--weight-semi); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconYes { + color: var(--color-success); +} + +.iconNo { + color: var(--color-error); +} + +.iconPending { + color: var(--color-text-lighter); +} + +.buttons { + display: flex; + gap: var(--s-3); + justify-content: center; +} diff --git a/app/features/sendouq-match/components/RematchVotePanel.tsx b/app/features/sendouq-match/components/RematchVotePanel.tsx new file mode 100644 index 000000000..9e809af82 --- /dev/null +++ b/app/features/sendouq-match/components/RematchVotePanel.tsx @@ -0,0 +1,131 @@ +import { Check, Clock, RotateCcw, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { type FetcherWithComponents, Link } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; +import * as RejoinVote from "../core/RejoinVote"; +import styles from "./RematchVotePanel.module.css"; + +export type RematchVoteMember = { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customUrl: string | null; +}; + +type RematchVotePanelProps = { + members: RematchVoteMember[]; + votes: RejoinVote.RejoinVote[]; + viewerUserId: number; + fetcher: FetcherWithComponents; +}; + +export function RematchVotePanel({ + members, + votes, + viewerUserId, + fetcher, +}: RematchVotePanelProps) { + const { t } = useTranslation(["q"]); + + const isPending = fetcher.state !== "idle"; + + const currentRoundSize = RejoinVote.currentUserIds( + votes, + members.map((m) => m.id), + ).length; + + const voteResult = RejoinVote.result(votes); + const voteResolved = voteResult.type === "RESOLVED"; + const voteFailed = voteResult.type === "FAILED"; + const viewerVotedYes = + RejoinVote.userContinueStatus(votes, viewerUserId) === true; + const viewerVotedNo = + RejoinVote.userContinueStatus(votes, viewerUserId) === false; + + return ( +
+
+ {voteFailed + ? t("q:match.rematch.fizzled") + : voteResolved + ? t("q:match.rematch.resolved", { count: currentRoundSize }) + : t("q:match.rematch.prompt", { count: currentRoundSize })} +
+
    + {members.map((member) => { + const status = RejoinVote.userContinueStatus(votes, member.id); + return ( +
  • + + {member.username} + +
  • + ); + })} +
+ {voteResolved && viewerVotedYes ? ( +
+ + }> + {t("q:match.rematch.backToQueue")} + + +
+ ) : voteFailed || viewerVotedNo ? null : ( +
+ + + {t("q:match.rematch.vote.no")} + + + + fetcher.submit( + { + _action: "CAST_CONTINUE_VOTE", + isContinuing: "1", + }, + { method: "post" }, + ) + } + > + {t("q:match.rematch.vote.yes")} + +
+ )} +
+ ); +} + +function StatusIcon({ status }: { status: boolean | null }) { + if (status === true) { + return ( + + ); + } + if (status === false) { + return ; + } + return ( + + ); +} diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.module.css b/app/features/sendouq-match/components/SendouQMatchActionTab.module.css new file mode 100644 index 000000000..96d70be6b --- /dev/null +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.module.css @@ -0,0 +1,40 @@ +.cancelWaiting { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + color: var(--color-text-low); + font-weight: var(--weight-semi); + text-align: center; +} + +.cancelRespondRoot { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-4); +} + +.cancelRespondHeader { + font-size: var(--font-md); + font-weight: var(--weight-semi); + text-align: center; +} + +.cancelRespondButtons { + display: flex; + gap: var(--s-3); +} + +.rematchContent { + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.divider { + border: none; + border-top: 2px solid var(--color-border); + margin-block: var(--s-2); + width: 100%; +} diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx new file mode 100644 index 000000000..ac7776787 --- /dev/null +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -0,0 +1,524 @@ +import type { TFunction } from "i18next"; +import { Ban, Check, Undo2, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { MatchActionTab } from "~/components/match-page/MatchActionTab"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { MatchTimeline } from "~/components/match-page/MatchTimeline"; +import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponReport"; +import { WeaponReporter } from "~/components/match-page/WeaponReporter"; +import { useUser } from "~/features/auth/core/user"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { + resolveGroupNames, + resolveTimelineMaps, + resolveTimelineTeams, +} from "../core/match-timeline"; +import * as SendouQMatch from "../core/SendouQMatch"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; +import { MatchmadeRejoinSection, TrustedRejoinSection } from "./RejoinSections"; +import styles from "./SendouQMatchActionTab.module.css"; + +export function SendouQMatchActionTab({ + data, + currentMap, + ownTeamId, + reportedCount, + viewerSide, +}: { + data: SendouQMatchLoaderData; + currentMap?: { stageId: StageId; mode: ModeShort }; + ownTeamId: number | null; + reportedCount: number; + viewerSide: "ALPHA" | "BRAVO" | null; +}) { + const user = useUser(); + if (!user) return null; + + const isStaffOnly = ownTeamId == null; + if (data.match.isCanceled) return null; + + const { isDecisive } = SendouQMatch.score(data.match); + const awaitingConfirmation = !data.match.isLocked && isDecisive; + const isLocked = data.match.isLocked; + + const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: data.match.cancelRequestedByUserId, + }); + const cancelRequestedByGroupId = + cancelRequesterSide === "ALPHA" + ? data.match.groupAlpha.id + : cancelRequesterSide === "BRAVO" + ? data.match.groupBravo.id + : undefined; + + if ( + !awaitingConfirmation && + !isLocked && + !isStaffOnly && + cancelRequestedByGroupId === ownTeamId + ) { + return ; + } + + if ( + !awaitingConfirmation && + !isLocked && + !isStaffOnly && + cancelRequestedByGroupId != null && + cancelRequestedByGroupId !== ownTeamId + ) { + return ; + } + + if (isLocked) { + return ( + + ); + } + + if (awaitingConfirmation) { + return ( + + ); + } + + if (currentMap) { + return ( + + ); + } + + return null; +} + +function CancelPendingTab() { + const { t } = useTranslation(["q"]); + return ( + +
+ {t("q:match.cancelPendingConfirmation")} +
+
+ ); +} + +function CancelRespondTab() { + const { t } = useTranslation(["q", "common"]); + const cancelFetcher = useFetcher(); + + return ( + +
+
+ {t("q:match.action.acceptCancelingSet")} +
+
+ } + isDisabled={cancelFetcher.state !== "idle"} + onPress={() => { + cancelFetcher.submit( + { _action: "REFUSE_CANCEL" }, + { method: "post" }, + ); + }} + > + {t("common:actions.refuse")} + + } + isDisabled={cancelFetcher.state !== "idle"} + onPress={() => { + cancelFetcher.submit( + { _action: "ACCEPT_CANCEL" }, + { method: "post" }, + ); + }} + > + {t("common:actions.accept")} + +
+
+
+ ); +} + +function RequeueTab({ + data, + viewerSide, + isStaffOnly, + awaitingConfirmation, +}: { + data: SendouQMatchLoaderData; + viewerSide: "ALPHA" | "BRAVO" | null; + isStaffOnly: boolean; + awaitingConfirmation: boolean; +}) { + const { t } = useTranslation(["q"]); + const user = useUser(); + + const { alphaWins, bravoWins } = SendouQMatch.score(data.match); + const score = { alpha: alphaWins, bravo: bravoWins }; + const teams = resolveTimelineTeams(data.match, t); + const maps = resolveTimelineMaps(data.match, data.reportedWeapons); + + const viewerGroup = + viewerSide === "ALPHA" + ? data.match.groupAlpha + : viewerSide === "BRAVO" + ? data.match.groupBravo + : null; + + const decidingReportedByUserId = [...data.match.mapList] + .reverse() + .find((m) => m.winnerGroupId !== null)?.reportedByUserId; + const reporterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: decidingReportedByUserId, + }); + const isOnReporterTeam = awaitingConfirmation && reporterSide === viewerSide; + const isOnConfirmerTeam = + awaitingConfirmation && + reporterSide !== null && + reporterSide !== viewerSide; + + const showTimeline = !data.match.isLocked; + + return ( + + {isStaffOnly || !viewerGroup || !user ? ( + showTimeline ? ( + + ) : null + ) : ( +
+ {viewerGroup.matchmade ? ( + + ) : null} + {!viewerGroup.matchmade && + (!awaitingConfirmation || isOnReporterTeam) ? ( + + ) : null} + {isOnReporterTeam ?
: null} + + {showTimeline ? ( + + ) : null} + {isOnConfirmerTeam ? : null} + {isOnReporterTeam ? : null} + +
+ )} +
+ ); +} + +function WeaponReportSection({ + data, + viewerUserId, +}: { + data: SendouQMatchLoaderData; + viewerUserId: number; +}) { + const completedMaps = data.match.mapList.filter( + (m) => m.winnerGroupId !== null, + ); + + const pastReported = data.reportedWeapons + ? data.reportedWeapons + .filter((w) => w.userId === viewerUserId) + .map((w) => ({ mapIndex: w.mapIndex, weaponSplId: w.weaponSplId })) + : []; + + const weaponReport = useMatchWeaponReport({ + maps: completedMaps.map((m) => ({ stageId: m.stageId, mode: m.mode })), + pastReported, + }); + + if (completedMaps.length === 0) return null; + + return ; +} + +function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { + const { t } = useTranslation(["q"]); + const fetcher = useFetcher(); + const confirmFetcherPending = fetcher.state !== "idle"; + + const decidingMap = [...data.match.mapList] + .reverse() + .find((m) => m.winnerGroupId !== null); + const reportedCount = data.match.mapList.filter( + (m) => m.winnerGroupId !== null, + ).length; + + return ( +
+ { + if (!decidingMap?.winnerGroupId) return; + fetcher.submit( + { + _action: "REPORT_SCORE", + winnerId: String(decidingMap.winnerGroupId), + reportedCount: String(reportedCount), + }, + { method: "post" }, + ); + }} + > + {t("q:match.confirmScore")} + +

+ {t("q:match.confirmScore.wrongHint")} +

+
+ ); +} + +function ReporterUndoSection() { + const { t } = useTranslation(["q"]); + const undoFetcher = useFetcher(); + + return ( +
+

+ {t("q:match.waitingForConfirmation")} +

+ { + undoFetcher.submit( + { _action: "UNDO_MATCH_REPORT" }, + { method: "post" }, + ); + }} + > + {t("q:match.undoReport")} + +
+ ); +} + +function InProgressTab({ + data, + currentMap, + ownTeamId, + reportedCount, + user, +}: { + data: SendouQMatchLoaderData; + currentMap: { stageId: StageId; mode: ModeShort }; + ownTeamId: number | null; + reportedCount: number; + user: { id: number }; +}) { + const { t } = useTranslation(["q", "common"]); + const fetcher = useFetcher(); + const undoFetcher = useFetcher(); + const cancelFetcher = useFetcher(); + + const isStaffOnly = ownTeamId == null; + + const { + mapsToWin, + alphaWins: alphaScore, + bravoWins: bravoScore, + } = SendouQMatch.score(data.match); + + const scores: [number, number] = [alphaScore, bravoScore]; + + const setEndingTeamIds: number[] = []; + if (alphaScore + 1 === mapsToWin) { + setEndingTeamIds.push(data.match.groupAlpha.id); + } + if (bravoScore + 1 === mapsToWin) { + setEndingTeamIds.push(data.match.groupBravo.id); + } + + const setEnding = + setEndingTeamIds.length > 0 + ? { + ...buildSendouQSetEndingData({ + match: data.match, + scores, + t, + }), + setEndingTeamIds, + } + : undefined; + + const scoreIsNotZero = alphaScore > 0 || bravoScore > 0; + + const weaponReport = useMatchWeaponReport({ + maps: data.match.mapList + .slice(0, reportedCount + 1) + .map((m) => ({ stageId: m.stageId, mode: m.mode })), + pastReported: data.reportedWeapons + ? data.reportedWeapons + .filter((w) => w.userId === user.id) + .map((w) => ({ mapIndex: w.mapIndex, weaponSplId: w.weaponSplId })) + : [], + }); + + const groupNames = resolveGroupNames(data.match, t); + + return ( + { + fetcher.submit( + { + _action: "REPORT_SCORE", + winnerId: String(winnerId), + reportedCount: String(reportedCount), + }, + { method: "post" }, + ); + }} + weaponReport={isStaffOnly ? undefined : weaponReport} + actionButtons={ + <> + {isStaffOnly ? ( + + } + > + {t("q:match.action.adminCancel")} + + + ) : ( + + } + > + {t("q:match.action.requestCancel")} + + + )} + {scoreIsNotZero ? ( + } + isPending={undoFetcher.state !== "idle"} + onPress={() => { + const mapIndex = data.match.mapList.findLastIndex( + (m) => m.winnerGroupId !== null, + ); + if (mapIndex < 0) return; + undoFetcher.submit( + { + _action: "UNDO_MAP_REPORT", + mapIndex: String(mapIndex), + }, + { method: "post" }, + ); + }} + > + {t("q:match.undoReport")} + + ) : null} + + } + /> + ); +} + +function buildSendouQSetEndingData({ + match, + scores, + t, +}: { + match: SendouQMatchLoaderData["match"]; + scores: [number, number]; + t: TFunction<["q"]>; +}) { + const completedMaps = match.mapList.filter((m) => m.winnerGroupId !== null); + + const previousMaps = completedMaps.map((map) => ({ + stageId: map.stageId, + mode: map.mode, + timestamp: Date.now(), + winner: + map.winnerGroupId === match.groupAlpha.id + ? ("ALPHA" as const) + : ("BRAVO" as const), + rosters: { + alpha: match.groupAlpha.members, + bravo: match.groupBravo.members, + }, + })); + + return { + teams: resolveTimelineTeams(match, t), + score: { alpha: scores[0], bravo: scores[1] }, + maps: previousMaps, + currentRosters: { + alpha: match.groupAlpha.members, + bravo: match.groupBravo.members, + }, + }; +} diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx new file mode 100644 index 000000000..7f17064e4 --- /dev/null +++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx @@ -0,0 +1,188 @@ +import { differenceInMinutes } from "date-fns"; +import { Ban, Vote } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { + IconBanner, + MatchBanner, + MatchBannerContainer, + MultiMatchBanner, +} from "~/components/match-page/MatchBanner"; +import bannerStyles from "~/components/match-page/MatchBanner.module.css"; +import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow"; +import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow"; +import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; +import { useAutoRerender } from "~/hooks/useAutoRerender"; +import { databaseTimestampToDate } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { resolveGroupNames } from "../core/match-timeline"; +import * as SendouQMatch from "../core/SendouQMatch"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; + +export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) { + const { t } = useTranslation(["q"]); + + const cancelRequested = Boolean(data.match.cancelRequestedByUserId); + const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: data.match.cancelRequestedByUserId, + }); + const groupNames = resolveGroupNames(data.match, t); + const cancelRequesterName = cancelRequested + ? cancelRequesterSide === "ALPHA" + ? groupNames.alpha + : groupNames.bravo + : undefined; + + const bottomRow = ( + ({ + 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, + }} + /> + ); + + const awaitingConfirmation = + !data.match.isLocked && SendouQMatch.score(data.match).isDecisive; + + if (data.match.isLocked || awaitingConfirmation) { + const playedStageIds = data.match.mapList + .filter((m) => m.winnerGroupId !== null) + .map((m) => m.stageId); + + return ( + + + {data.match.isCanceled ? ( + } header={t("q:match.canceled")} /> + ) : ( + + )} + {bottomRow} + + ); + } + + const currentMap = data.match.currentMap; + invariant(currentMap); + + return ( + + + {cancelRequesterName ? ( + } + header={t("q:match.cancelRequested")} + subtitle={t("q:match.cancelRequested.subtitle", { + teamName: cancelRequesterName, + })} + /> + ) : ( + + + + )} + {bottomRow} + + ); +} + +function SendouQMatchBannerTopRow({ + data, + awaitingConfirmation, +}: { + data: SendouQMatchLoaderData; + awaitingConfirmation: boolean; +}) { + const now = useAutoRerender("ten seconds"); + + const { alphaWins, bravoWins } = SendouQMatch.score(data.match); + + const startedAt = databaseTimestampToDate(data.match.createdAt); + + const lastMapReportedAt = data.match.mapList.reduce( + (acc, m) => + m.reportedAt && (!acc || m.reportedAt > acc) ? m.reportedAt : acc, + null, + ); + const lastReportAt = lastMapReportedAt + ? databaseTimestampToDate(lastMapReportedAt) + : startedAt; + + return ( + + ); +} + +function CurrentMapVotesBadge({ + voters, +}: { + voters: NonNullable["voters"]; +}) { + const { t } = useTranslation(["q"]); + + if (voters.length === 0) return null; + + return ( + + {voters.length} + + } + > +
+
+ {t("q:match.mapVoters.header")} +
+ {voters.map((voter) => ( +
+ + {voter.username} +
+ ))} +
+
+ ); +} diff --git a/app/features/sendouq-match/components/SendouQMatchHeader.tsx b/app/features/sendouq-match/components/SendouQMatchHeader.tsx new file mode 100644 index 000000000..0a729da06 --- /dev/null +++ b/app/features/sendouq-match/components/SendouQMatchHeader.tsx @@ -0,0 +1,34 @@ +import { Scale } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { LinkButton } from "~/components/elements/Button"; +import { MatchPageHeader } from "~/components/match-page/MatchPageHeader"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { SENDOUQ_RULES_PAGE } from "~/utils/urls"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; + +export function SendouQMatchHeader({ data }: { data: SendouQMatchLoaderData }) { + const { t } = useTranslation(["q"]); + + const season = Seasons.currentOrPrevious( + databaseTimestampToDate(data.match.createdAt), + )?.nth; + + return ( + } + > + {t("q:front.nav.rules.title")} + + } + > + {t("q:match.header", { number: data.match.id })} + + ); +} diff --git a/app/features/sendouq-match/components/SendouQMatchTabs.tsx b/app/features/sendouq-match/components/SendouQMatchTabs.tsx new file mode 100644 index 000000000..280e0d9dd --- /dev/null +++ b/app/features/sendouq-match/components/SendouQMatchTabs.tsx @@ -0,0 +1,238 @@ +import { useTranslation } from "react-i18next"; +import { useNavigate, useSearchParams } from "react-router"; +import { MatchJoinTab } from "~/components/match-page/MatchJoinTab"; +import { MatchResultTab } from "~/components/match-page/MatchResultTab"; +import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; +import { MatchTabs } from "~/components/match-page/MatchTabs"; +import { resolveRoomPass } from "~/components/match-page/utils"; +import { useUser } from "~/features/auth/core/user"; +import { + resolveActiveRoomLink, + useConfirmRoom, +} from "~/features/chat/room-link-utils"; +import { ACTION_TAB_AFTER_LOCKED_SECONDS } from "~/features/sendouq/q-constants"; +import { useHasRole } from "~/modules/permissions/hooks"; +import { databaseTimestampNow } from "~/utils/dates"; +import { safeNumberParse } from "~/utils/number"; +import { sendouQMatchPage, teamPage } from "~/utils/urls"; +import { + resolveTimelineMaps, + resolveTimelineSpChanges, + resolveTimelineTeams, +} from "../core/match-timeline"; +import * as SendouQMatch from "../core/SendouQMatch"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; +import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog"; +import { SendouQMatchActionTab } from "./SendouQMatchActionTab"; + +export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { + const user = useUser(); + const isStaff = useHasRole("STAFF"); + const { onConfirmRoom, isConfirming } = useConfirmRoom(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { t } = useTranslation(["q"]); + + const currentMap = data.match.currentMap; + + const userSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: user?.id, + }); + const isStaffOnly = isStaff && !userSide; + const ownTeamId = + userSide === "ALPHA" + ? data.match.groupAlpha.id + : userSide === "BRAVO" + ? data.match.groupBravo.id + : isStaffOnly + ? null + : data.match.groupAlpha.id; + + const { alphaWins, bravoWins, isDecisive } = SendouQMatch.score(data.match); + const awaitingConfirmation = !data.match.isLocked && isDecisive; + const isLocked = data.match.isLocked; + const isCanceled = data.match.isCanceled; + + const isParticipant = Boolean(userSide); + + const lockedActionTabVisible = + data.match.confirmedAt !== null && + databaseTimestampNow() < + data.match.confirmedAt + ACTION_TAB_AFTER_LOCKED_SECONDS; + + const matchInProgress = !isLocked && !awaitingConfirmation && currentMap; + + const showActionTab = + (isParticipant || (isStaffOnly && Boolean(matchInProgress))) && + !isCanceled && + (matchInProgress || + awaitingConfirmation || + (isLocked && lockedActionTabVisible)); + + const hasReportedMaps = data.match.mapList.some( + (m) => m.winnerGroupId !== null, + ); + + const tabs: Array<"join" | "rosters" | "action" | "result"> = []; + if (isLocked) { + tabs.push("result", "rosters"); + } else { + if (isParticipant) tabs.push("join"); + tabs.push("rosters"); + } + if (showActionTab) tabs.push("action"); + if (!isLocked && hasReportedMaps) tabs.push("result"); + + const allMembers = [ + ...data.match.groupAlpha.members, + ...data.match.groupBravo.members, + ]; + + const activeRoomLink = resolveActiveRoomLink({ + roomLinks: data.roomLinks, + freshnessCutoff: data.match.createdAt, + viewerUserId: user?.id, + members: allMembers, + }); + + const ownGroup = + userSide === "ALPHA" + ? data.match.groupAlpha + : userSide === "BRAVO" + ? data.match.groupBravo + : null; + const addingNoteFor = ownGroup?.members.find( + (m) => m.id === safeNumberParse(searchParams.get("note")), + ); + + return ( + <> + navigate(sendouQMatchPage(data.match.id))} + /> + + {isLocked || hasReportedMaps ? ( + + {data.match.cancelRequestedByUserId ? ( +

+ {t("q:match.canceled.detail", { + requester: resolveCancelRequesterUsername(data.match), + accepter: resolveCancelAccepterUsername(data.match), + })} +

+ ) : null} +
+ ) : null} + {!isLocked && isParticipant ? ( + + ) : null} + + {showActionTab ? ( + m.winnerGroupId !== null).length + } + viewerSide={userSide} + /> + ) : null} +
+ + ); +} + +type MatchData = SendouQMatchLoaderData["match"]; + +function resolveCancelRequesterUsername(match: MatchData) { + const allMembers = [...match.groupAlpha.members, ...match.groupBravo.members]; + return ( + allMembers.find((m) => m.id === match.cancelRequestedByUserId)?.username ?? + "?" + ); +} + +function resolveCancelAccepterUsername(match: MatchData) { + const allMembers = [...match.groupAlpha.members, ...match.groupBravo.members]; + return ( + allMembers.find((m) => m.id === match.cancelAcceptedByUserId)?.username ?? + "?" + ); +} + +function mapRosterMembers( + members: MatchData["groupAlpha"]["members"], + { viewerId, isOwnTeam }: { viewerId?: number; isOwnTeam: boolean }, +) { + return members.map((member) => ({ + ...member, + tier: + member.skill === "CALCULATING" + ? ("CALCULATING" as const) + : member.skill?.tier, + plusTier: member.plusTier ?? undefined, + weaponPool: member.weapons?.map((w) => w.weaponSplId), + friendCode: member.friendCode, + privateNote: + viewerId !== undefined && isOwnTeam && member.id !== viewerId + ? (member.privateNote ?? null) + : undefined, + })); +} + +function mapRosterTeam( + team: { + id: number; + name: string; + customUrl: string; + avatarUrl: string | null; + } | null, +) { + if (!team) return undefined; + return { + id: team.id, + name: team.name, + url: teamPage(team.customUrl), + avatar: team.avatarUrl ?? undefined, + }; +} diff --git a/app/features/sendouq-match/core/RejoinVote.test.ts b/app/features/sendouq-match/core/RejoinVote.test.ts new file mode 100644 index 000000000..0d5f79858 --- /dev/null +++ b/app/features/sendouq-match/core/RejoinVote.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import type { SQMatch } from "~/features/sendouq/core/SendouQ.server"; +import * as RejoinVote from "./RejoinVote"; + +type MatchInput = Pick; + +function groupWith( + members: Array<{ id: number; isContinuing: boolean | null }>, +) { + return { members } as unknown as MatchInput["groupAlpha"]; +} + +describe("RejoinVote.result()", () => { + test("is ONGOING until the whole group has voted", () => { + expect( + RejoinVote.result([ + { userId: 1, isContinuing: true }, + { userId: 2, isContinuing: true }, + { userId: 3, isContinuing: true }, + ]), + ).toEqual({ type: "ONGOING" }); + }); + + test("resolves with the ids of members who chose to continue", () => { + const result = RejoinVote.result([ + { userId: 1, isContinuing: true }, + { userId: 2, isContinuing: false }, + { userId: 3, isContinuing: true }, + { userId: 4, isContinuing: false }, + ]); + + expect(result).toEqual({ + type: "RESOLVED", + continuingUserIds: [1, 3], + }); + }); + + test("fails when fewer than two members want to continue", () => { + expect( + RejoinVote.result([ + { userId: 1, isContinuing: true }, + { userId: 2, isContinuing: false }, + { userId: 3, isContinuing: false }, + { userId: 4, isContinuing: false }, + ]), + ).toEqual({ type: "FAILED" }); + }); +}); + +describe("RejoinVote.userContinueStatus()", () => { + test("returns null when the user has not voted", () => { + expect(RejoinVote.userContinueStatus([], 1)).toBeNull(); + }); + + test("returns the user's vote", () => { + const votes = [ + { userId: 1, isContinuing: false }, + { userId: 2, isContinuing: true }, + ]; + + expect(RejoinVote.userContinueStatus(votes, 2)).toBe(true); + expect(RejoinVote.userContinueStatus(votes, 1)).toBe(false); + }); +}); + +describe("RejoinVote.canCastVote()", () => { + test("is true when the user has not voted yet", () => { + expect(RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1)).toBe( + true, + ); + }); + + test("is false once the user has voted", () => { + expect( + RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1), + ).toBe(false); + }); +}); + +describe("RejoinVote.extractOwnGroupVotesFromSendouqMatch()", () => { + const match = { + groupAlpha: groupWith([ + { id: 1, isContinuing: true }, + { id: 2, isContinuing: null }, + { id: 3, isContinuing: false }, + ]), + groupBravo: groupWith([{ id: 10, isContinuing: true }]), + } satisfies MatchInput; + + test("returns only the cast votes of the user's own group", () => { + expect(RejoinVote.extractOwnGroupVotesFromSendouqMatch(match, 1)).toEqual([ + { userId: 1, isContinuing: true }, + { userId: 3, isContinuing: false }, + ]); + }); + + test("returns null when the user is in neither group", () => { + expect( + RejoinVote.extractOwnGroupVotesFromSendouqMatch(match, 999), + ).toBeNull(); + }); +}); + +describe("RejoinVote.currentUserIds()", () => { + test("filters out members who voted against continuing", () => { + const result = RejoinVote.currentUserIds( + [ + { userId: 1, isContinuing: true }, + { userId: 2, isContinuing: false }, + ], + [1, 2, 3, 4], + ); + + expect(result).toEqual([1, 3, 4]); + }); +}); diff --git a/app/features/sendouq-match/core/RejoinVote.ts b/app/features/sendouq-match/core/RejoinVote.ts new file mode 100644 index 000000000..af5844557 --- /dev/null +++ b/app/features/sendouq-match/core/RejoinVote.ts @@ -0,0 +1,96 @@ +import type { SQMatch } from "~/features/sendouq/core/SendouQ.server"; +import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import * as SendouQMatch from "./SendouQMatch"; + +export interface RejoinVote { + userId: number; + isContinuing: boolean; +} + +const MIN_CONTINUING_GROUP_SIZE = 2; + +/** + * Resolves the overall vote state. ONGOING until every member of a full group + * has cast a vote, then RESOLVED with the ids of those who chose to continue, + * or FAILED if too few want to continue to form a viable group. + */ +export function result(votes: RejoinVote[]) { + if (votes.length !== FULL_GROUP_SIZE) { + return { type: "ONGOING" as const }; + } + + const continuingUserIds = votes + .filter((vote) => vote.isContinuing) + .map((vote) => vote.userId); + + if (continuingUserIds.length < MIN_CONTINUING_GROUP_SIZE) { + return { type: "FAILED" as const }; + } + + return { + type: "RESOLVED" as const, + continuingUserIds, + }; +} + +/** + * Returns the given user's vote (true/false), or null if they have not voted. + */ +export function userContinueStatus(votes: RejoinVote[], userId: number) { + return votes.find((vote) => vote.userId === userId)?.isContinuing ?? null; +} + +/** + * Whether the given user is still eligible to cast their vote. + */ +export function canCastVote(votes: RejoinVote[], userId: number) { + return !votes.some((vote) => vote.userId === userId); +} + +/** + * Collects the votes cast within the viewing user's own group. Returns null if + * the user is not a member of either side of the match. + */ +export function extractOwnGroupVotesFromSendouqMatch( + match: Pick, + userId: number, +): RejoinVote[] | null { + const ownSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId, + }); + const ownGroup = + ownSide === "ALPHA" + ? match.groupAlpha + : ownSide === "BRAVO" + ? match.groupBravo + : null; + + if (!ownGroup) return null; + + return ownGroup.members.flatMap((member) => + typeof member.isContinuing === "boolean" + ? { + userId: member.id, + isContinuing: member.isContinuing, + } + : [], + ); +} + +/** + * Returns the group member ids that remain after removing anyone who voted + * against continuing. + */ +export function currentUserIds( + votes: RejoinVote[], + groupMemberIds: number[], +): number[] { + const dropped = new Set(droppedUserIds(votes)); + return groupMemberIds.filter((id) => !dropped.has(id)); +} + +function droppedUserIds(votes: RejoinVote[]): number[] { + return votes.filter((v) => !v.isContinuing).map((v) => v.userId); +} diff --git a/app/features/sendouq-match/core/SendouQMatch.test.ts b/app/features/sendouq-match/core/SendouQMatch.test.ts new file mode 100644 index 000000000..b984a33b2 --- /dev/null +++ b/app/features/sendouq-match/core/SendouQMatch.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; +import * as SendouQMatch from "./SendouQMatch"; + +const ALPHA_ID = 1; +const BRAVO_ID = 2; + +function matchWith(winners: Array) { + return { + groupAlpha: { id: ALPHA_ID }, + groupBravo: { id: BRAVO_ID }, + mapList: winners.map((winnerGroupId) => ({ winnerGroupId })), + }; +} + +describe("SendouQMatch.score()", () => { + test("no maps reported yet", () => { + const result = SendouQMatch.score( + matchWith([null, null, null, null, null, null, null]), + ); + + expect(result.alphaWins).toBe(0); + expect(result.bravoWins).toBe(0); + expect(result.isDecisive).toBe(false); + }); + + test("ongoing, no side has enough wins", () => { + const result = SendouQMatch.score( + matchWith([ALPHA_ID, BRAVO_ID, ALPHA_ID, null, null, null, null]), + ); + + expect(result.alphaWins).toBe(2); + expect(result.bravoWins).toBe(1); + expect(result.isDecisive).toBe(false); + }); + + test("decisive when a side reaches mapsToWin", () => { + const result = SendouQMatch.score( + matchWith([ALPHA_ID, ALPHA_ID, ALPHA_ID, ALPHA_ID, null, null, null]), + ); + + expect(result.alphaWins).toBe(result.mapsToWin); + expect(result.isDecisive).toBe(true); + }); +}); diff --git a/app/features/sendouq-match/core/SendouQMatch.ts b/app/features/sendouq-match/core/SendouQMatch.ts new file mode 100644 index 000000000..ba3c8f939 --- /dev/null +++ b/app/features/sendouq-match/core/SendouQMatch.ts @@ -0,0 +1,49 @@ +import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; + +/** + * Calculates the current map win counts for each group in a SendouQ match and + * indicates whether the match has been decided (i.e. one group has reached the + * required number of map wins for the configured best-of). + */ +export function score(match: { + mapList: Array<{ winnerGroupId: number | null }>; + groupAlpha: { id: number }; + groupBravo: { id: number }; +}) { + const mapsToWin = Math.ceil(SENDOUQ_BEST_OF / 2); + const alphaWins = match.mapList.filter( + (m) => m.winnerGroupId === match.groupAlpha.id, + ).length; + const bravoWins = match.mapList.filter( + (m) => m.winnerGroupId === match.groupBravo.id, + ).length; + + return { + mapsToWin, + alphaWins, + bravoWins, + isDecisive: alphaWins >= mapsToWin || bravoWins >= mapsToWin, + }; +} + +/** + * Returns which side ("ALPHA" or "BRAVO") of the match the given user belongs + * to, or null if they are not a member of either group. + */ +export function resolveGroupMemberOf(args: { + groupAlpha: { members: { id: number }[] }; + groupBravo: { members: { id: number }[] }; + userId: number | null | undefined; +}): "ALPHA" | "BRAVO" | null { + if (!args.userId) return null; + + if (args.groupAlpha.members.some((m) => m.id === args.userId)) { + return "ALPHA"; + } + + if (args.groupBravo.members.some((m) => m.id === args.userId)) { + return "BRAVO"; + } + + return null; +} diff --git a/app/features/sendouq-match/core/match-timeline.test.ts b/app/features/sendouq-match/core/match-timeline.test.ts new file mode 100644 index 000000000..c8a4e6971 --- /dev/null +++ b/app/features/sendouq-match/core/match-timeline.test.ts @@ -0,0 +1,228 @@ +import type { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; +import { + resolveGroupNames, + resolveTimelineMaps, + resolveTimelineSpChanges, + resolveTimelineTeams, +} from "./match-timeline"; + +type MatchData = SendouQMatchLoaderData["match"]; + +const ALPHA_ID = 10; +const BRAVO_ID = 20; + +const t = ((key: string) => key) as unknown as TFunction<["q"]>; + +function member(overrides: Record = {}) { + return { + id: 1, + username: "user", + discordId: "d", + discordAvatar: null, + customUrl: null, + skillDifference: undefined, + ...overrides, + }; +} + +function matchWith(overrides: Record = {}): MatchData { + return { + createdAt: 1_700_000_000, + mapList: [], + groupAlpha: { + id: ALPHA_ID, + team: null, + members: [], + }, + groupBravo: { + id: BRAVO_ID, + team: null, + members: [], + }, + ...overrides, + } as unknown as MatchData; +} + +describe("resolveGroupNames()", () => { + test("uses team names when both sides have a registered team", () => { + const result = resolveGroupNames( + matchWith({ + groupAlpha: { id: ALPHA_ID, team: { name: "Squids" }, members: [] }, + groupBravo: { id: BRAVO_ID, team: { name: "Octos" }, members: [] }, + }), + t, + ); + + expect(result).toEqual({ alpha: "Squids", bravo: "Octos" }); + }); + + test("falls back to translation keys when team is missing", () => { + const result = resolveGroupNames(matchWith(), t); + + expect(result).toEqual({ + alpha: "q:match.groupAlpha", + bravo: "q:match.groupBravo", + }); + }); +}); + +describe("resolveTimelineTeams()", () => { + test("exposes avatar url when the team has one", () => { + const result = resolveTimelineTeams( + matchWith({ + groupAlpha: { + id: ALPHA_ID, + team: { name: "Squids", avatarUrl: "a.png" }, + members: [], + }, + groupBravo: { id: BRAVO_ID, team: null, members: [] }, + }), + t, + ); + + expect(result.alpha.avatar).toBe("a.png"); + expect(result.bravo.avatar).toBeUndefined(); + }); +}); + +describe("resolveTimelineMaps()", () => { + test("filters out maps that have not been reported", () => { + const result = resolveTimelineMaps( + matchWith({ + mapList: [ + { id: 1, stageId: 1, mode: "SZ", winnerGroupId: ALPHA_ID }, + { id: 2, stageId: 2, mode: "TC", winnerGroupId: null }, + ], + }), + [], + ); + + expect(result).toHaveLength(1); + expect(result[0].winner).toBe("ALPHA"); + }); + + test("resolves winner based on matching group id", () => { + const result = resolveTimelineMaps( + matchWith({ + mapList: [{ id: 1, stageId: 1, mode: "SZ", winnerGroupId: BRAVO_ID }], + }), + [], + ); + + expect(result[0].winner).toBe("BRAVO"); + }); + + test("omits weapons when nothing is reported", () => { + const result = resolveTimelineMaps( + matchWith({ + mapList: [{ id: 1, stageId: 1, mode: "SZ", winnerGroupId: ALPHA_ID }], + groupAlpha: { + id: ALPHA_ID, + team: null, + members: [member({ id: 1 })], + }, + groupBravo: { + id: BRAVO_ID, + team: null, + members: [member({ id: 2 })], + }, + }), + [], + ); + + expect(result[0].weapons).toBeUndefined(); + }); + + test("includes weapons when at least one player reported", () => { + const result = resolveTimelineMaps( + matchWith({ + mapList: [{ id: 1, stageId: 1, mode: "SZ", winnerGroupId: ALPHA_ID }], + groupAlpha: { + id: ALPHA_ID, + team: null, + members: [member({ id: 1 })], + }, + groupBravo: { + id: BRAVO_ID, + team: null, + members: [member({ id: 2 })], + }, + }), + [{ mapIndex: 0, userId: 1, weaponSplId: 40 }] as never, + ); + + expect(result[0].weapons).toEqual({ + alpha: [40], + bravo: [null], + }); + }); +}); + +describe("resolveTimelineSpChanges()", () => { + test("returns undefined when nobody has a skill difference", () => { + const result = resolveTimelineSpChanges( + matchWith({ + groupAlpha: { + id: ALPHA_ID, + team: null, + members: [member({ id: 1 })], + skillDifference: undefined, + }, + groupBravo: { + id: BRAVO_ID, + team: null, + members: [member({ id: 2 })], + skillDifference: undefined, + }, + }), + ); + + expect(result).toBeUndefined(); + }); + + test("collects only members that actually have a skill difference", () => { + const result = resolveTimelineSpChanges( + matchWith({ + groupAlpha: { + id: ALPHA_ID, + team: null, + members: [ + member({ id: 1, skillDifference: { spDiff: 5 } }), + member({ id: 2 }), + ], + }, + groupBravo: { + id: BRAVO_ID, + team: null, + members: [member({ id: 3 })], + }, + }), + ); + + expect(result?.alpha.members).toHaveLength(1); + expect(result?.alpha.members[0].user.id).toBe(1); + expect(result?.bravo.members).toHaveLength(0); + }); + + test("returns data when only the group itself has a skill difference", () => { + const result = resolveTimelineSpChanges( + matchWith({ + groupAlpha: { + id: ALPHA_ID, + team: null, + members: [member({ id: 1 })], + skillDifference: { calculated: true }, + }, + groupBravo: { + id: BRAVO_ID, + team: null, + members: [member({ id: 2 })], + }, + }), + ); + + expect(result?.alpha.skillDifference).toEqual({ calculated: true }); + }); +}); diff --git a/app/features/sendouq-match/core/match-timeline.ts b/app/features/sendouq-match/core/match-timeline.ts new file mode 100644 index 000000000..cd51da73d --- /dev/null +++ b/app/features/sendouq-match/core/match-timeline.ts @@ -0,0 +1,124 @@ +import type { TFunction } from "i18next"; +import type { + TimelineMap, + TimelineSpChanges, +} from "~/components/match-page/MatchTimeline"; +import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; + +type MatchData = SendouQMatchLoaderData["match"]; + +/** + * Resolves display names for the two groups in a match, falling back to the + * translated "Group Alpha"/"Group Bravo" labels when a group is not associated + * with a registered team. + */ +export function resolveGroupNames(match: MatchData, t: TFunction<["q"]>) { + return { + alpha: match.groupAlpha.team?.name ?? t("q:match.groupAlpha"), + bravo: match.groupBravo.team?.name ?? t("q:match.groupBravo"), + }; +} + +export function resolveTimelineTeams(match: MatchData, t: TFunction<["q"]>) { + const names = resolveGroupNames(match, t); + return { + alpha: { + name: names.alpha, + avatar: match.groupAlpha.team?.avatarUrl ?? undefined, + }, + bravo: { + name: names.bravo, + avatar: match.groupBravo.team?.avatarUrl ?? undefined, + }, + }; +} + +export function resolveTimelineMaps( + match: MatchData, + reportedWeapons: SendouQMatchLoaderData["reportedWeapons"], +): TimelineMap[] { + return match.mapList + .map((map, mapIndex) => ({ map, mapIndex })) + .filter(({ map }) => map.winnerGroupId !== null) + .map(({ map, mapIndex }) => { + const alphaWeapons = match.groupAlpha.members.map((member) => { + const w = reportedWeapons?.find( + (rw) => rw.mapIndex === mapIndex && rw.userId === member.id, + ); + return w?.weaponSplId ?? null; + }); + const bravoWeapons = match.groupBravo.members.map((member) => { + const w = reportedWeapons?.find( + (rw) => rw.mapIndex === mapIndex && rw.userId === member.id, + ); + return w?.weaponSplId ?? null; + }); + + const hasAnyWeapon = + alphaWeapons.some((w) => w !== null) || + bravoWeapons.some((w) => w !== null); + + return { + stageId: map.stageId, + mode: map.mode, + timestamp: databaseTimestampToJavascriptTimestamp( + map.reportedAt ?? match.createdAt, + ), + winner: + map.winnerGroupId === match.groupAlpha.id + ? ("ALPHA" as const) + : ("BRAVO" as const), + rosters: { + alpha: match.groupAlpha.members, + bravo: match.groupBravo.members, + }, + weapons: hasAnyWeapon + ? { alpha: alphaWeapons, bravo: bravoWeapons } + : undefined, + }; + }); +} + +export function resolveTimelineSpChanges( + match: MatchData, +): TimelineSpChanges | undefined { + const resolveMembers = ( + group: MatchData["groupAlpha"] | MatchData["groupBravo"], + ) => + group.members + .filter((m) => m.skillDifference) + .map((m) => ({ + user: { + id: m.id, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + }, + skillDifference: m.skillDifference!, + })); + + const alphaMembers = resolveMembers(match.groupAlpha); + const bravoMembers = resolveMembers(match.groupBravo); + + if ( + alphaMembers.length === 0 && + bravoMembers.length === 0 && + !match.groupAlpha.skillDifference && + !match.groupBravo.skillDifference + ) { + return undefined; + } + + return { + alpha: { + members: alphaMembers, + skillDifference: match.groupAlpha.skillDifference, + }, + bravo: { + members: bravoMembers, + skillDifference: match.groupBravo.skillDifference, + }, + }; +} diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index 5fc125ab8..640e219bd 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -303,7 +303,7 @@ export function compareMatchToReportedScores({ newReporterGroupId, previousReporterGroupId, }: { - match: Pick & { + match: Pick & { groupAlpha: { id: number }; groupBravo: { id: number }; }; @@ -312,7 +312,9 @@ export function compareMatchToReportedScores({ previousReporterGroupId?: number; }) { // match has not been reported before - if (!match.reportedByUserId) return "FIRST_REPORT"; + if (!match.mapList.some((m) => m.reportedByUserId !== null)) { + return "FIRST_REPORT"; + } const sameGroupReporting = newReporterGroupId === previousReporterGroupId; const differentConstant = sameGroupReporting ? "FIX_PREVIOUS" : "DIFFERENT"; diff --git a/app/features/sendouq-match/core/match.ts b/app/features/sendouq-match/core/match.ts deleted file mode 100644 index bb2f5403d..000000000 --- a/app/features/sendouq-match/core/match.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; - -export function matchEndedAtIndex(scores: ("ALPHA" | "BRAVO")[]) { - let alphaCount = 0; - let bravoCount = 0; - let matchEndedAt = -1; - - const mapsToWin = Math.ceil(SENDOUQ_BEST_OF / 2); - for (const [i, winner] of scores.entries()) { - if (winner === "ALPHA") alphaCount++; - if (winner === "BRAVO") bravoCount++; - - if (alphaCount === mapsToWin || bravoCount === mapsToWin) { - matchEndedAt = i; - break; - } - } - - if (matchEndedAt === -1) return null; - - return matchEndedAt; -} diff --git a/app/features/sendouq-match/core/reported-weapons.server.test.ts b/app/features/sendouq-match/core/reported-weapons.server.test.ts index 0be1a82b9..e15cf22fe 100644 --- a/app/features/sendouq-match/core/reported-weapons.server.test.ts +++ b/app/features/sendouq-match/core/reported-weapons.server.test.ts @@ -5,7 +5,7 @@ import { mergeReportedWeapons } from "./reported-weapons.server"; describe("mergeReportedWeapons()", () => { const newWeapons = [ { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 1, weaponSplId: 0 as MainWeaponId, @@ -23,7 +23,7 @@ describe("mergeReportedWeapons()", () => { newWeapons, oldWeapons: [ { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 1, weaponSplId: 1 as MainWeaponId, @@ -39,7 +39,7 @@ describe("mergeReportedWeapons()", () => { newWeapons, oldWeapons: [ { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 2, weaponSplId: 0 as MainWeaponId, @@ -49,7 +49,7 @@ describe("mergeReportedWeapons()", () => { expect(result).toEqual([ { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 2, weaponSplId: 0 as MainWeaponId, @@ -63,13 +63,13 @@ describe("mergeReportedWeapons()", () => { newWeapons, oldWeapons: [ { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 1, weaponSplId: 1 as MainWeaponId, }, { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 2, weaponSplId: 0 as MainWeaponId, @@ -80,7 +80,7 @@ describe("mergeReportedWeapons()", () => { expect(result).toEqual([ ...newWeapons, { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 0, userId: 2, weaponSplId: 0 as MainWeaponId, @@ -93,7 +93,7 @@ describe("mergeReportedWeapons()", () => { newWeapons, oldWeapons: [ { - groupMatchMapId: 1, + groupMatchId: 1, mapIndex: 1, userId: 1, weaponSplId: 0 as MainWeaponId, diff --git a/app/features/sendouq-match/core/reported-weapons.server.ts b/app/features/sendouq-match/core/reported-weapons.server.ts index 9e5d53e44..6aa2cac5e 100644 --- a/app/features/sendouq-match/core/reported-weapons.server.ts +++ b/app/features/sendouq-match/core/reported-weapons.server.ts @@ -1,12 +1,9 @@ -import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import type * as ReportedWeaponRepository from "../ReportedWeaponRepository.server"; -import type * as SQMatchRepository from "../SQMatchRepository.server"; export type ReportedWeaponForMerging = { weaponSplId?: MainWeaponId; mapIndex: number; - groupMatchMapId: number; + groupMatchId: number; userId: number; }; type ReportedWeapon = ReportedWeaponForMerging & { weaponSplId: MainWeaponId }; @@ -25,7 +22,8 @@ export function mergeReportedWeapons({ for (const oldWeapon of oldWeapons) { const replacement = newWeapons.find( (newWeapon) => - newWeapon.groupMatchMapId === oldWeapon.groupMatchMapId && + newWeapon.groupMatchId === oldWeapon.groupMatchId && + newWeapon.mapIndex === oldWeapon.mapIndex && newWeapon.userId === oldWeapon.userId, ); @@ -41,7 +39,8 @@ export function mergeReportedWeapons({ if ( !result.some( (oldWeapon) => - newWeapon.groupMatchMapId === oldWeapon.groupMatchMapId && + newWeapon.groupMatchId === oldWeapon.groupMatchId && + newWeapon.mapIndex === oldWeapon.mapIndex && newWeapon.userId === oldWeapon.userId, ) ) { @@ -58,43 +57,3 @@ export function mergeReportedWeapons({ typeof w.weaponSplId === "number" ? [w as ReportedWeapon] : [], ); } - -export function reportedWeaponsToArrayOfArrays({ - reportedWeapons, - mapList, - groupAlpha, - groupBravo, -}: { - reportedWeapons: Awaited< - ReturnType - >; - mapList: NonNullable< - Awaited> - >["mapList"]; - groupAlpha: SQMatchGroup; - groupBravo: SQMatchGroup; -}) { - if (!reportedWeapons) return null; - - const result: (MainWeaponId | null)[][] = []; - - const allMembers = [...groupAlpha.members, ...groupBravo.members].map( - (m) => m.id, - ); - - for (const map of mapList) { - const mapWeapons: (MainWeaponId | null)[] = []; - - for (const userId of allMembers) { - const reportedWeapon = reportedWeapons.find( - (wpn) => wpn.groupMatchMapId === map.id && wpn.userId === userId, - ); - - mapWeapons.push(reportedWeapon ? reportedWeapon.weaponSplId : null); - } - - result.push(mapWeapons); - } - - return result; -} diff --git a/app/features/sendouq-match/core/skills.server.ts b/app/features/sendouq-match/core/skills.server.ts index 42c136480..3304d6573 100644 --- a/app/features/sendouq-match/core/skills.server.ts +++ b/app/features/sendouq-match/core/skills.server.ts @@ -190,11 +190,13 @@ function userSkillDifference({ const calculated = matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; if (calculated) { + const oldSp = ordinalToSp(ordinal(oldRating)); + const newSp = ordinalToSp(ordinal(newRating)); return { calculated, - spDiff: roundToNDecimalPlaces( - ordinalToSp(ordinal(newRating)) - ordinalToSp(ordinal(oldRating)), - ), + spDiff: roundToNDecimalPlaces(newSp - oldSp), + oldSp, + newSp, }; } 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..afef68ca7 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -1,12 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server"; 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 * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; +import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { qMatchPageParamsSchema } from "../q-match-schemas"; @@ -16,6 +18,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { params, schema: qMatchPageParamsSchema, }).id; + const matchUnmapped = notFoundIfFalsy( await SQMatchRepository.findById(matchId), ); @@ -24,27 +27,24 @@ 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 [privateNotes, roomLinks, anyUserPrefersNoSplatnet, reportedWeapons] = + await Promise.all([ + user + ? PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers) + : undefined, + RoomLinkRepository.findByUserIds(matchUsers, 3), + UserRepository.anyUserPrefersNoSplatnet(matchUsers), + ReportedWeaponRepository.findByMatchId(matchId), + ]); 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, + roomLinks, + anyUserPrefersNoSplatnet, + reportedWeapons, chatCode: (() => { const isStaff = user?.roles.includes("STAFF") ?? false; const isParticipant = user && matchUsers.includes(user.id); @@ -72,3 +72,5 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { })(), }; }; + +export type SendouQMatchLoaderData = SerializeFrom; diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index f28b77bb5..3a341ef60 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -1,64 +1,25 @@ import { z } from "zod"; -import { SENDOUQ, SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; -import { - _action, - checkboxValueToBoolean, - falsyToNull, - id, - safeJSONParse, - weaponSplId, -} from "~/utils/zod"; -import { matchEndedAtIndex } from "./core/match"; +import { SENDOUQ } from "~/features/sendouq/q-constants"; +import { _action, falsyToNull, id, weaponSplId } from "~/utils/zod"; -const winners = z.preprocess( - safeJSONParse, - z - .array(z.enum(["ALPHA", "BRAVO"])) - .max(SENDOUQ_BEST_OF) - .refine((val) => { - if (val.length === 0) return true; - - const matchEndedAt = matchEndedAtIndex(val); - - // match did end - if (matchEndedAt === null) return true; - - // no extra scores after match ended - return val.length === matchEndedAt + 1; - }), -); - -const weapons = z.preprocess( - safeJSONParse, - z - .array( - z.object({ - weaponSplId, - userId: id, - mapIndex: z.number().int().nonnegative(), - groupMatchMapId: id, - }), - ) - .optional() - .default([]), -); export const matchSchema = z.union([ z.object({ _action: _action("REPORT_SCORE"), - winners, - weapons, - adminReport: z.preprocess( - checkboxValueToBoolean, - z.boolean().nullish().default(false), - ), + winnerId: id, + reportedCount: z.coerce.number().int().nonnegative(), }), z.object({ _action: _action("LOOK_AGAIN"), previousGroupId: id, }), z.object({ - _action: _action("REPORT_WEAPONS"), - weapons, + _action: _action("CAST_CONTINUE_VOTE"), + isContinuing: z.enum(["0", "1"]).transform((v) => Number(v) as 0 | 1), + }), + z.object({ + _action: _action("REPORT_WEAPON"), + weaponSplId, + mapIndex: z.coerce.number().int().nonnegative(), }), z.object({ _action: _action("ADD_PRIVATE_USER_NOTE"), @@ -69,6 +30,29 @@ export const matchSchema = z.union([ sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]), targetId: id, }), + z.object({ + _action: _action("UNDO_MATCH_REPORT"), + }), + z.object({ + _action: _action("UNDO_MAP_REPORT"), + mapIndex: z.coerce.number().int().nonnegative(), + }), + z.object({ + _action: _action("UNDO_WEAPON_REPORT"), + mapIndex: z.coerce.number().int().nonnegative(), + }), + z.object({ + _action: _action("REQUEST_CANCEL"), + }), + z.object({ + _action: _action("ACCEPT_CANCEL"), + }), + z.object({ + _action: _action("REFUSE_CANCEL"), + }), + z.object({ + _action: _action("ADMIN_CANCEL"), + }), ]); export const qMatchPageParamsSchema = z.object({ diff --git a/app/features/sendouq-match/q-match-utils.ts b/app/features/sendouq-match/q-match-utils.ts index ff91e8c89..12b400cf4 100644 --- a/app/features/sendouq-match/q-match-utils.ts +++ b/app/features/sendouq-match/q-match-utils.ts @@ -7,21 +7,3 @@ export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) { return null; } - -export function resolveGroupMemberOf(args: { - groupAlpha: { members: { id: number }[] }; - groupBravo: { members: { id: number }[] }; - userId: number | undefined; -}): "ALPHA" | "BRAVO" | null { - if (!args.userId) return null; - - if (args.groupAlpha.members.some((m) => m.id === args.userId)) { - return "ALPHA"; - } - - if (args.groupBravo.members.some((m) => m.id === args.userId)) { - return "BRAVO"; - } - - return null; -} 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 1baeaa16a..ae854a5b6 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -1,72 +1,18 @@ -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 { 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 type { MetaFunction } from "react-router"; +import { useLoaderData } from "react-router"; 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 { MatchPage } from "~/components/match-page/MatchPage"; 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 { - navIconUrl, - preferenceEmojiUrl, - SENDOU_INK_DISCORD_URL, - SENDOUQ_PAGE, - SENDOUQ_RULES_PAGE, - sendouQMatchPage, - specialWeaponImageUrl, - teamPage, -} from "~/utils/urls"; +import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls"; import { action } from "../actions/q.match.$id.server"; -import { matchEndedAtIndex } from "../core/match"; +import { SendouQMatchBanner } from "../components/SendouQMatchBanner"; +import { SendouQMatchHeader } from "../components/SendouQMatchHeader"; +import { SendouQMatchTabs } from "../components/SendouQMatchTabs"; 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; @@ -84,7 +30,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["q", "tournament", "user"], + i18n: ["q"], breadcrumb: () => ({ imgPath: navIconUrl("sendouq"), href: SENDOUQ_PAGE, @@ -92,1354 +38,16 @@ export const handle: SendouRouteHandle = { }), }; -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(); +export default function SendouQMatchPage() { 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"))); 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(); - 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"]); - - return ( - - {t("q:match.dispute.button")} - - } - > -

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

-

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

-
- ); -} - -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"]); - const data = useLoaderData(); - const lookAgainFetcher = useFetcher(); - - const wasReportedInTheLastHour = - databaseTimestampToDate(reportedAt).getTime() > Date.now() - 3600 * 1000; - - const season = Seasons.current(); - 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 ( -
- - - {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} -
- ); -} - -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); - } - }; - - 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 ? ( - - - {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 sourceTeams = () => { - if (!data.match.memento?.pools) return []; - - const pickerGroups = [data.match.groupAlpha, data.match.groupBravo].filter( - (g) => map.source === "BOTH" || String(g.id) === map.source, - ); - - const teams: Array<{ name: string; avatarUrl: string | null }> = []; - for (const pickerGroup of pickerGroups) { - for (const poolEntry of data.match.memento.pools) { - if (!poolEntry.teamName) continue; - if (!pickerGroup.members.some((m) => m.id === poolEntry.userId)) { - continue; - } - - const modePool = poolEntry.pool.find((p) => p.mode === map.mode); - if ( - modePool?.stages.includes(map.stageId) && - !teams.some((t) => t.name === poolEntry.teamName) - ) { - teams.push({ - name: poolEntry.teamName, - avatarUrl: - pickerGroup.team?.name === poolEntry.teamName - ? pickerGroup.team.avatarUrl - : null, - }); - } - } - } - - return teams; - }; - - const mapPreferences = data.match.memento?.mapPreferences?.[i]; - const teams = sourceTeams(); - const poolMemberIds = sourcePoolMemberIds(); - const showPopover = - (mapPreferences && mapPreferences.length > 0) || - map.source === "DEFAULT" || - poolMemberIds.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")} -
- ) : teams.length > 0 ? ( -
- {teams.map((team) => ( -
- - {team.name} -
- ))} -
- ) : poolMemberIds.length > 0 ? ( -
- {poolMemberIds.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-settings/QSettingsRepository.server.ts b/app/features/sendouq-settings/QSettingsRepository.server.ts index 598f6a6c0..8f749feb1 100644 --- a/app/features/sendouq-settings/QSettingsRepository.server.ts +++ b/app/features/sendouq-settings/QSettingsRepository.server.ts @@ -13,6 +13,7 @@ export async function settingsByUserId(userId: number) { "User.languages", "User.qWeaponPool", "User.noScreen", + "User.noSplatnet", ]) .where("id", "=", userId) .executeTakeFirstOrThrow(); @@ -141,6 +142,22 @@ export function updateNoScreen({ .execute(); } +export function updateNoSplatnet({ + noSplatnet, + userId, +}: { + noSplatnet: number; + userId: number; +}) { + return db + .updateTable("User") + .set({ + noSplatnet, + }) + .where("User.id", "=", userId) + .execute(); +} + /** * Preserves existing preferences for modes not included in the new submission. * So if they later want to play this mode again, the system remembers their maps. diff --git a/app/features/sendouq-streams/routes/q.streams.tsx b/app/features/sendouq-streams/routes/q.streams.tsx index a9fe755dd..f96247aa2 100644 --- a/app/features/sendouq-streams/routes/q.streams.tsx +++ b/app/features/sendouq-streams/routes/q.streams.tsx @@ -126,11 +126,13 @@ export default function SendouQStreamsPage() { function RelativeStartTime({ startedAt }: { startedAt: Date }) { const { i18n } = useTranslation(); const isHydrated = useHydrated(); - useAutoRerender(); + const now = useAutoRerender(); if (!isHydrated) return null; - const minutesAgo = Math.floor((startedAt.getTime() - Date.now()) / 1000 / 60); + const minutesAgo = Math.floor( + (startedAt.getTime() - now.getTime()) / 1000 / 60, + ); const formatter = new Intl.RelativeTimeFormat(i18n.language, { style: "short", }); diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts new file mode 100644 index 000000000..300779447 --- /dev/null +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as SQGroupRepository from "./SQGroupRepository.server"; + +const MATCH_CHAT_CODE = "match-chat"; + +const setupConcludedMatch = async () => { + const alphaGroup = await db + .insertInto("Group") + .values({ + inviteCode: "inv-alpha", + chatCode: "chat-alpha", + status: "INACTIVE", + matchmade: 1, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + const bravoGroup = await db + .insertInto("Group") + .values({ + inviteCode: "inv-bravo", + chatCode: "chat-bravo", + status: "INACTIVE", + matchmade: 1, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .insertInto("GroupMember") + .values([ + { groupId: alphaGroup.id, userId: 1, role: "OWNER" }, + { groupId: alphaGroup.id, userId: 2, role: "REGULAR" }, + { groupId: bravoGroup.id, userId: 3, role: "OWNER" }, + { groupId: bravoGroup.id, userId: 4, role: "REGULAR" }, + ]) + .execute(); + + await db + .insertInto("GroupMatch") + .values({ + alphaGroupId: alphaGroup.id, + bravoGroupId: bravoGroup.id, + chatCode: MATCH_CHAT_CODE, + }) + .execute(); + + return { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id }; +}; + +const fetchVotes = (groupId: number) => + db + .selectFrom("GroupMatchContinueVote") + .selectAll() + .where("groupId", "=", groupId) + .execute(); + +describe("createGroup", () => { + beforeEach(async () => { + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("records implicit no-vote on previous matchmade group when user creates a new group", async () => { + const { alphaGroupId } = await setupConcludedMatch(); + + const votesBefore = await fetchVotes(alphaGroupId); + expect(votesBefore).toHaveLength(0); + + const result = await SQGroupRepository.createGroup({ + status: "ACTIVE", + userId: 1, + }); + + const votes = await fetchVotes(alphaGroupId); + expect(votes).toHaveLength(1); + expect(votes[0].userId).toBe(1); + expect(votes[0].isContinuing).toBe(0); + expect(result.chatCodeToRevalidate).toBe(MATCH_CHAT_CODE); + }); + + test("preserves existing vote when user already voted yes on previous match", async () => { + const { alphaGroupId } = await setupConcludedMatch(); + + await db + .insertInto("GroupMatchContinueVote") + .values({ groupId: alphaGroupId, userId: 1, isContinuing: 1 }) + .execute(); + + const result = await SQGroupRepository.createGroup({ + status: "ACTIVE", + userId: 1, + }); + + const votes = await fetchVotes(alphaGroupId); + expect(votes).toHaveLength(1); + expect(votes[0].isContinuing).toBe(1); + expect(result.chatCodeToRevalidate).toBeNull(); + }); + + test("clears other members' yes votes on the previous group when recording implicit no", async () => { + const { alphaGroupId } = await setupConcludedMatch(); + + await db + .insertInto("GroupMatchContinueVote") + .values({ groupId: alphaGroupId, userId: 2, isContinuing: 1 }) + .execute(); + + const votesBefore = await fetchVotes(alphaGroupId); + expect(votesBefore[0].userId).toBe(2); + + await SQGroupRepository.createGroup({ status: "ACTIVE", userId: 1 }); + + const votes = await fetchVotes(alphaGroupId); + expect(votes).toHaveLength(1); + expect(votes[0].userId).toBe(1); + expect(votes[0].isContinuing).toBe(0); + }); + + test("does not record any vote when user has no previous matchmade group", async () => { + const result = await SQGroupRepository.createGroup({ + status: "ACTIVE", + userId: 1, + }); + + const allVotes = await db + .selectFrom("GroupMatchContinueVote") + .selectAll() + .execute(); + expect(allVotes).toHaveLength(0); + expect(result.chatCodeToRevalidate).toBeNull(); + }); +}); + +describe("addMember", () => { + beforeEach(async () => { + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("records implicit no-vote on previous matchmade group when user joins another group", async () => { + const { alphaGroupId } = await setupConcludedMatch(); + + const newGroup = await SQGroupRepository.createGroup({ + status: "PREPARING", + userId: 5, + }); + + const { chatCodeToRevalidate } = await SQGroupRepository.addMember( + newGroup.id, + { userId: 1 }, + ); + + const votes = await fetchVotes(alphaGroupId); + expect(votes).toHaveLength(1); + expect(votes[0].userId).toBe(1); + expect(votes[0].isContinuing).toBe(0); + expect(chatCodeToRevalidate).toBe(MATCH_CHAT_CODE); + }); +}); diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index be13d9129..96ab38dcd 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -131,7 +131,7 @@ type CreateGroupArgs = { status: Exclude; userId: number; }; -export function createGroup(args: CreateGroupArgs) { +export async function createGroup(args: CreateGroupArgs) { return db.transaction().execute(async (trx) => { const createdGroup = await trx .insertInto("Group") @@ -156,7 +156,12 @@ export function createGroup(args: CreateGroupArgs) { throw new SendouQError("Group has a member in multiple groups"); } - return createdGroup; + const chatCodeToRevalidate = await recordImplicitRejoinNoVote( + args.userId, + trx, + ); + + return { id: createdGroup.id, chatCodeToRevalidate }; }); } @@ -166,14 +171,18 @@ type CreateGroupFromPreviousGroupArgs = { id: number; role: Tables["GroupMember"]["role"]; }[]; + status?: Exclude; }; export async function createGroupFromPrevious( args: CreateGroupFromPreviousGroupArgs, ) { + const status = args.status ?? "PREPARING"; + const membersWithEnsuredOwner = ensureOwnerRole(args.members); + return db.transaction().execute(async (trx) => { const createdGroup = await trx .insertInto("Group") - .columns(["teamId", "chatCode", "inviteCode", "status"]) + .columns(["teamId", "chatCode", "inviteCode", "status", "matchmade"]) .expression((eb) => eb .selectFrom("Group") @@ -181,7 +190,8 @@ export async function createGroupFromPrevious( "Group.teamId", "Group.chatCode", eb.val(shortNanoid()).as("inviteCode"), - eb.val("PREPARING").as("status"), + eb.val(status).as("status"), + "Group.matchmade", ]) .where("Group.id", "=", args.previousGroupId), ) @@ -191,7 +201,7 @@ export async function createGroupFromPrevious( await trx .insertInto("GroupMember") .values( - args.members.map((member) => ({ + membersWithEnsuredOwner.map((member) => ({ groupId: createdGroup.id, userId: member.id, role: member.role, @@ -209,6 +219,19 @@ export async function createGroupFromPrevious( }); } +function ensureOwnerRole( + members: CreateGroupFromPreviousGroupArgs["members"], +): CreateGroupFromPreviousGroupArgs["members"] { + if (members.some((m) => m.role === "OWNER")) return members; + + const promoteeIndex = members.findIndex((m) => m.role === "MANAGER"); + const targetIndex = promoteeIndex !== -1 ? promoteeIndex : 0; + + return members.map((m, i) => + i === targetIndex ? { ...m, role: "OWNER" as const } : m, + ); +} + function deleteLikesByGroupId(groupId: number, trx: Transaction) { return trx .deleteFrom("GroupLike") @@ -229,10 +252,10 @@ export function morphGroups({ otherGroupId: number; }) { return db.transaction().execute(async (trx) => { - // reset chat code so previous messages are not visible + // reset chat code so previous messages are not visible, and mark as matchmade await trx .updateTable("Group") - .set({ chatCode: shortNanoid() }) + .set({ chatCode: shortNanoid(), matchmade: 1 }) .where("Group.id", "=", survivingGroupId) .execute(); @@ -313,7 +336,7 @@ async function isGroupCorrect( return true; } -export function addMember( +export async function addMember( groupId: number, { userId, @@ -323,7 +346,7 @@ export function addMember( role?: Tables["GroupMember"]["role"]; }, ) { - return db.transaction().execute(async (trx) => { + const chatCodeToRevalidate = await db.transaction().execute(async (trx) => { await trx .insertInto("GroupMember") .values({ @@ -340,7 +363,11 @@ export function addMember( "Group has too many members or member in multiple groups", ); } + + return recordImplicitRejoinNoVote(userId, trx); }); + + return { chatCodeToRevalidate }; } export async function allLikesByGroupId(groupId: number) { @@ -490,6 +517,66 @@ export async function setOldGroupsAsInactive() { .executeTakeFirst(); }); } +export async function closeExpiredContinueVotes() { + const cutoff = dateToDatabaseTimestamp(sub(new Date(), { hours: 1 })); + + return db.transaction().execute(async (trx) => { + const eligibleGroups = await trx + .selectFrom("Group") + .innerJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .innerJoin("GroupMember", "GroupMember.groupId", "Group.id") + .leftJoin("GroupMatchContinueVote", (join) => + join + .onRef("GroupMatchContinueVote.groupId", "=", "Group.id") + .onRef("GroupMatchContinueVote.userId", "=", "GroupMember.userId"), + ) + .select(["Group.id as groupId", "GroupMatch.chatCode as matchChatCode"]) + .where("Group.matchmade", "=", 1) + .where("GroupMatch.confirmedAt", "is not", null) + .where("GroupMatch.confirmedAt", "<", cutoff) + .where("GroupMatchContinueVote.id", "is", null) + .groupBy("Group.id") + .execute(); + + const chatCodesToRevalidate: string[] = []; + + for (const { groupId, matchChatCode } of eligibleGroups) { + const members = await trx + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "=", groupId) + .execute(); + + await trx + .insertInto("GroupMatchContinueVote") + .values( + members.map((m) => ({ + groupId, + userId: m.userId, + isContinuing: 0 as const, + })), + ) + .onConflict((oc) => + oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing: 0 }), + ) + .execute(); + + if (matchChatCode) chatCodesToRevalidate.push(matchChatCode); + } + + return { + chatCodesToRevalidate, + numAffectedGroups: eligibleGroups.length, + }; + }); +} export async function mapModePreferencesBySeasonNth(seasonNth: number) { return db @@ -523,8 +610,8 @@ export async function findRecentlyFinishedMatches() { .whereRef("GroupMember.groupId", "=", "GroupMatch.bravoGroupId"), ).as("groupBravoMemberIds"), ]) - .where("GroupMatch.reportedAt", "is not", null) - .where("GroupMatch.reportedAt", ">", dateToDatabaseTimestamp(twoHoursAgo)) + .where("GroupMatch.confirmedAt", "is not", null) + .where("GroupMatch.confirmedAt", ">", dateToDatabaseTimestamp(twoHoursAgo)) .execute(); return rows.map((row) => ({ @@ -705,3 +792,51 @@ export function setAsInactive(groupId: number, trx?: Transaction) { .where("id", "=", groupId) .execute(); } +async function recordImplicitRejoinNoVote( + userId: number, + trx: Transaction, +): Promise { + const candidate = await trx + .selectFrom("GroupMember") + .innerJoin("Group", "Group.id", "GroupMember.groupId") + .innerJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .leftJoin("GroupMatchContinueVote", (join) => + join + .onRef("GroupMatchContinueVote.groupId", "=", "Group.id") + .on("GroupMatchContinueVote.userId", "=", userId), + ) + .select(["Group.id as groupId", "GroupMatch.chatCode as matchChatCode"]) + .where("GroupMember.userId", "=", userId) + .where("Group.matchmade", "=", 1) + .where("GroupMatchContinueVote.id", "is", null) + .executeTakeFirst(); + + if (!candidate) return null; + + await trx + .deleteFrom("GroupMatchContinueVote") + .where("GroupMatchContinueVote.groupId", "=", candidate.groupId) + .where("GroupMatchContinueVote.isContinuing", "=", 1) + .execute(); + + await trx + .insertInto("GroupMatchContinueVote") + .values({ + groupId: candidate.groupId, + userId, + isContinuing: 0, + }) + .onConflict((oc) => + oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing: 0 }), + ) + .execute(); + + return candidate.matchChatCode; +} diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index a7c4a1368..44fc1391e 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "react-router"; import { redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { notify } from "~/features/notifications/core/notify.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; @@ -9,7 +10,7 @@ import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; import { preparingSchema } from "../q-schemas.server"; -import { setGroupChatMetadata } from "../q-utils.server"; +import { SendouQError, setGroupChatMetadata } from "../q-utils.server"; export type SendouQPreparingAction = typeof action; @@ -31,56 +32,77 @@ export const action = async ({ request }: ActionFunctionArgs) => { const season = Seasons.current(); errorToastIfFalsy(season, "Season is not active"); - switch (data._action) { - case "JOIN_QUEUE": { - await SQGroupRepository.setPreparingGroupAsActive(ownGroup.id); + try { + switch (data._action) { + case "JOIN_QUEUE": { + await SQGroupRepository.setPreparingGroupAsActive(ownGroup.id); - await refreshSendouQInstance(); + await refreshSendouQInstance(); - return redirect(SENDOUQ_LOOKING_PAGE); - } - case "ADD_FRIEND": { - const available = await SQGroupRepository.findActiveGroupMembers(); - if (available.some(({ userId }) => userId === data.id)) { - return { error: "taken" } as const; + return redirect(SENDOUQ_LOOKING_PAGE); } + case "ADD_FRIEND": { + const available = await SQGroupRepository.findActiveGroupMembers(); + if (available.some(({ userId }) => userId === data.id)) { + return { error: "taken" } as const; + } - errorToastIfFalsy( - (await SQGroupRepository.friendsAndTeammates(user.id)).friends.some( - (friendUser) => friendUser.id === data.id, - ), - "Not a friend", - ); + errorToastIfFalsy( + (await SQGroupRepository.friendsAndTeammates(user.id)).friends.some( + (friendUser) => friendUser.id === data.id, + ), + "Not a friend", + ); - await SQGroupRepository.addMember(ownGroup.id, { - userId: data.id, - role: "MANAGER", - }); - - await refreshSendouQInstance(); - - const updatedGroup = SendouQ.findOwnGroup(user.id); - if (updatedGroup?.chatCode) { - setGroupChatMetadata({ - chatCode: updatedGroup.chatCode, - members: updatedGroup.members, - }); - } - - notify({ - userIds: [data.id], - notification: { - type: "SQ_ADDED_TO_GROUP", - meta: { - adderUsername: user.username, + const { chatCodeToRevalidate } = await SQGroupRepository.addMember( + ownGroup.id, + { + userId: data.id, + role: "MANAGER", }, - }, - }); + ); + if (chatCodeToRevalidate) { + ChatSystemMessage.send({ + room: chatCodeToRevalidate, + revalidateOnly: true, + }); + } + + await refreshSendouQInstance(); + + const updatedGroup = SendouQ.findOwnGroup(user.id); + if (updatedGroup?.chatCode) { + setGroupChatMetadata({ + chatCode: updatedGroup.chatCode, + members: updatedGroup.members, + }); + } + + notify({ + userIds: [data.id], + notification: { + type: "SQ_ADDED_TO_GROUP", + meta: { + adderUsername: user.username, + }, + }, + }); + + return null; + } + default: { + assertUnreachable(data); + } + } + } catch (error) { + // some errors are expected to happen, for example two requests racing to + // create/join a group. return null so loaders re-run and the user sees + // the fresh state instead of an error page + if (error instanceof SendouQError) { return null; } - default: { - assertUnreachable(data); - } + + throw error; } }; diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index af9a6b52b..0e83c5565 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -3,6 +3,7 @@ import { redirect } from "react-router"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -17,7 +18,7 @@ import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants"; import { frontPageSchema } from "../q-schemas.server"; import { userCanJoinQueueAt } from "../q-utils"; -import { setGroupChatMetadata } from "../q-utils.server"; +import { SendouQError, setGroupChatMetadata } from "../q-utils.server"; export const action: ActionFunction = async ({ request }) => { const user = requireUser(); @@ -26,91 +27,121 @@ export const action: ActionFunction = async ({ request }) => { schema: frontPageSchema, }); - switch (data._action) { - case "JOIN_QUEUE": { - await validateCanJoinQ(user); + try { + switch (data._action) { + case "JOIN_QUEUE": { + await validateCanJoinQ(user); - await SQGroupRepository.createGroup({ - status: data.direct === "true" ? "ACTIVE" : "PREPARING", - userId: user.id, - }); - - await refreshSendouQInstance(); - - return redirect( - data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE, - ); - } - case "JOIN_TEAM": { - await validateCanJoinQ(user); - - const code = new URL(request.url).searchParams.get( - JOIN_CODE_SEARCH_PARAM_KEY, - ); - - const groupInvitedTo = - code && user ? SendouQ.findGroupByInviteCode(code) : undefined; - errorToastIfFalsy( - groupInvitedTo, - "Invite code doesn't match any active team", - ); - - await SQGroupRepository.addMember(groupInvitedTo.id, { - userId: user.id, - role: "MANAGER", - }); - - await refreshSendouQInstance(); - - const joinedGroup = SendouQ.findOwnGroup(user.id); - if (joinedGroup?.chatCode) { - setGroupChatMetadata({ - chatCode: joinedGroup.chatCode, - members: joinedGroup.members, - }); - } - - return redirect( - groupInvitedTo.status === "PREPARING" - ? SENDOUQ_PREPARING_PAGE - : SENDOUQ_LOOKING_PAGE, - ); - } - case "ADD_FRIEND_CODE": { - errorToastIfFalsy( - !(await UserRepository.currentFriendCodeByUserId(user.id)), - "Friend code already set", - ); - - const isTakenFriendCode = ( - await UserRepository.allCurrentFriendCodes() - ).has(data.friendCode); - - await UserRepository.insertFriendCode({ - userId: user.id, - friendCode: data.friendCode, - submitterUserId: user.id, - }); - - if (isTakenFriendCode) { - await AdminRepository.banUser({ + const { chatCodeToRevalidate } = await SQGroupRepository.createGroup({ + status: data.direct === "true" ? "ACTIVE" : "PREPARING", userId: user.id, - banned: 1, - bannedReason: - "[automatic ban] This friend code is already in use by some other account. Please contact staff on our Discord helpdesk for resolution including merging accounts.", - bannedByUserId: null, }); - await refreshBannedCache(); + if (chatCodeToRevalidate) { + ChatSystemMessage.send({ + room: chatCodeToRevalidate, + revalidateOnly: true, + }); + } - throw redirect(SUSPENDED_PAGE); + await refreshSendouQInstance(); + + return redirect( + data.direct === "true" + ? SENDOUQ_LOOKING_PAGE + : SENDOUQ_PREPARING_PAGE, + ); } + case "JOIN_TEAM": { + await validateCanJoinQ(user); + const code = new URL(request.url).searchParams.get( + JOIN_CODE_SEARCH_PARAM_KEY, + ); + + const groupInvitedTo = + code && user ? SendouQ.findGroupByInviteCode(code) : undefined; + errorToastIfFalsy( + groupInvitedTo, + "Invite code doesn't match any active team", + ); + + const { chatCodeToRevalidate } = await SQGroupRepository.addMember( + groupInvitedTo.id, + { + userId: user.id, + role: "MANAGER", + }, + ); + + if (chatCodeToRevalidate) { + ChatSystemMessage.send({ + room: chatCodeToRevalidate, + revalidateOnly: true, + }); + } + + await refreshSendouQInstance(); + + const joinedGroup = SendouQ.findOwnGroup(user.id); + if (joinedGroup?.chatCode) { + setGroupChatMetadata({ + chatCode: joinedGroup.chatCode, + members: joinedGroup.members, + }); + } + + return redirect( + groupInvitedTo.status === "PREPARING" + ? SENDOUQ_PREPARING_PAGE + : SENDOUQ_LOOKING_PAGE, + ); + } + case "ADD_FRIEND_CODE": { + errorToastIfFalsy( + !(await UserRepository.currentFriendCodeByUserId(user.id)), + "Friend code already set", + ); + + const isTakenFriendCode = ( + await UserRepository.allCurrentFriendCodes() + ).has(data.friendCode); + + await UserRepository.insertFriendCode({ + userId: user.id, + friendCode: data.friendCode, + submitterUserId: user.id, + }); + + if (isTakenFriendCode) { + await AdminRepository.banUser({ + userId: user.id, + banned: 1, + bannedReason: + "[automatic ban] This friend code is already in use by some other account. Please contact staff on our Discord helpdesk for resolution including merging accounts.", + bannedByUserId: null, + }); + + await refreshBannedCache(); + + throw redirect(SUSPENDED_PAGE); + } + + return null; + } + default: { + assertUnreachable(data); + } + } + } catch (error) { + // some errors are expected to happen, for example two requests racing to + // create/join a group. return null so loaders re-run and the user sees + // the fresh state instead of an error page + if (error instanceof SendouQError) { return null; } - default: { - assertUnreachable(data); - } + + throw error; } }; diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 0d1fcc02b..9fa339152 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -33,8 +33,6 @@ import { import type { SQGroup, SQGroupMember, - SQMatchGroup, - SQMatchGroupMember, SQOwnGroup, } from "../core/SendouQ.server"; import { @@ -62,7 +60,7 @@ export function GroupCard({ showNote = false, ownGroup, }: { - group: SQGroup | SQOwnGroup | SQMatchGroup; + group: SQGroup | SQOwnGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE"; displayOnly?: boolean; hideVc?: SqlBool; @@ -90,16 +88,12 @@ export function GroupCard({ const enableKicking = group.usersRole === "OWNER" && !displayOnly; - // broke after Remix single fetch future flag got toggled on, not sure why this is needed - const members: Array | undefined = - group.members; - return (
- {members ? ( + {group.members ? (
- {members.map((member) => { + {group.members.map((member) => { return ( ; + member: Pick; }) { const user = useUser(); const { t } = useTranslation(["q"]); diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts index 76e27b2fc..a46b723fb 100644 --- a/app/features/sendouq/core/SendouQ.server.test.ts +++ b/app/features/sendouq/core/SendouQ.server.test.ts @@ -54,16 +54,16 @@ const createGroup = async ( const createMatch = async ( alphaGroupId: number, bravoGroupId: number, - options: { reportedAt?: number } = {}, + options: { confirmedAt?: number } = {}, ) => { - const { reportedAt = Date.now() } = options; + const { confirmedAt = Date.now() } = options; await db .insertInto("GroupMatch") .values({ alphaGroupId, bravoGroupId, - reportedAt, + confirmedAt, }) .execute(); }; @@ -378,6 +378,15 @@ describe("SendouQ", () => { const group1Id = await createGroup([2, 3, 4, 5]); const group2Id = await createGroup([6, 7, 8, 9]); + // Force identical latestActionAt so the sort comparator's + // recency tie-breaker stays neutral and the assertion does + // not depend on whether the group inserts straddle a + // millisecond boundary (which they can on slow CI). + await db + .updateTable("Group") + .set({ latestActionAt: databaseTimestampNow() }) + .where("id", "in", [group1Id, group2Id]) + .execute(); await refreshSendouQInstance(); const notes = await PrivateUserNoteRepository.byAuthorUserId(1); diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index ea65cd108..9da637582 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -7,6 +7,7 @@ import { defaultOrdinal } from "~/features/mmr/mmr-utils"; import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server"; import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch"; import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort } from "~/modules/in-game-lists/types"; @@ -43,7 +44,6 @@ export type SQOwnGroup = SerializeFrom< export type SQMatch = SerializeFrom>; export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"]; export type SQGroupMember = NonNullable[number]; -export type SQMatchGroupMember = SQMatchGroup["members"][number]; const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const; const SECONDS_TILL_STALE = @@ -168,14 +168,14 @@ class SendouQClass { /** Array of private user notes to include */ notes: DBPrivateNoteRow[] = [], ) { - const isTeamAlphaMember = match.groupAlpha.members.some( - (m) => m.id === user?.id, - ); - const isTeamBravoMember = match.groupBravo.members.some( - (m) => m.id === user?.id, - ); - const isMatchInsider = - isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF"); + const viewerSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: user?.id, + }); + const isTeamAlphaMember = viewerSide === "ALPHA"; + const isTeamBravoMember = viewerSide === "BRAVO"; + const isMatchInsider = viewerSide !== null || user?.roles.includes("STAFF"); const happenedInLastMonth = isWithinInterval( databaseTimestampToDate(match.createdAt), { @@ -190,14 +190,11 @@ class SendouQClass { ) => { return { ...group, - isReplay: false, - tierRange: null as TierRange | null, chatCode: isTeamMember ? group.chatCode : undefined, noScreen: this.#groupNoScreen(group), tier: match.memento?.groups[group.id]?.tier, skillDifference: match.memento?.groups[group.id]?.skillDifference, - modePreferences: this.#groupModePreferences(group), - usersRole: null as Tables["GroupMember"]["role"] | null, + matchmade: Boolean(group.matchmade), members: group.members.map((member) => { return { ...member, @@ -205,7 +202,10 @@ class SendouQClass { privateNote: null as DBPrivateNoteRow | null, skillDifference: match.memento?.users[member.id]?.skillDifference, noScreen: undefined, - languages: member.languages?.split(",") || [], + isContinuing: + typeof member.isContinuing === "number" + ? Boolean(member.isContinuing) + : null, friendCode: isMatchInsider && happenedInLastMonth ? member.friendCode @@ -215,15 +215,37 @@ class SendouQClass { }; }; + const alphaCensored = matchGroupCensorer( + match.groupAlpha, + isTeamAlphaMember, + ); + const bravoCensored = matchGroupCensorer( + match.groupBravo, + isTeamBravoMember, + ); + + const reportedMapsCount = match.mapList.filter( + (map) => map.winnerGroupId, + ).length; + const currentMapRaw = match.mapList.at(reportedMapsCount); + const currentMap = currentMapRaw + ? { + ...currentMapRaw, + voters: this.#currentMapVoters({ + currentMap: currentMapRaw, + groupAlpha: alphaCensored, + groupBravo: bravoCensored, + pools: match.memento?.pools, + }), + } + : undefined; + return { ...match, chatCode: isMatchInsider ? match.chatCode : undefined, - groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)( - matchGroupCensorer(match.groupAlpha, isTeamAlphaMember), - ), - groupBravo: this.#getAddMemberPrivateNoteMapper(notes)( - matchGroupCensorer(match.groupBravo, isTeamBravoMember), - ), + currentMap, + groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(alphaCensored), + groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(bravoCensored), }; } @@ -522,6 +544,58 @@ class SendouQClass { return group.members.length === FULL_GROUP_SIZE; } + #currentMapVoters({ + currentMap, + groupAlpha, + groupBravo, + pools, + }: { + currentMap: DBMatch["mapList"][number]; + groupAlpha: { + id: number; + members: Array<{ + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + }>; + }; + groupBravo: { + id: number; + members: Array<{ + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + }>; + }; + pools: ParsedMemento["pools"] | undefined; + }) { + if (!pools) return []; + + const pickerGroups = [groupAlpha, groupBravo].filter( + (g) => currentMap.source === "BOTH" || String(g.id) === currentMap.source, + ); + if (pickerGroups.length === 0) return []; + + return pickerGroups.flatMap((pickerGroup) => + pools.flatMap(({ userId, pool }) => { + const member = pickerGroup.members.find((m) => m.id === userId); + if (!member) return []; + const modePool = pool.find((p) => p.mode === currentMap.mode); + if (!modePool?.stages.includes(currentMap.stageId)) return []; + return [ + { + id: member.id, + username: member.username, + discordId: member.discordId, + discordAvatar: member.discordAvatar, + }, + ]; + }), + ); + } + #groupTier( group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"], ): TieredSkill["tier"] | undefined { diff --git a/app/features/sendouq/q-constants.ts b/app/features/sendouq/q-constants.ts index b2744b353..da39334e0 100644 --- a/app/features/sendouq/q-constants.ts +++ b/app/features/sendouq/q-constants.ts @@ -14,6 +14,8 @@ export const FULL_GROUP_SIZE = 4; export const SENDOUQ_BEST_OF = 7; +export const ACTION_TAB_AFTER_LOCKED_SECONDS = 24 * 60 * 60; // 24 hours + export const JOIN_CODE_SEARCH_PARAM_KEY = "join"; export const USER_SKILLS_CACHE_KEY = "user-skills"; diff --git a/app/features/sendouq/queries/addInitialSkill.server.ts b/app/features/sendouq/queries/addInitialSkill.server.ts deleted file mode 100644 index df40a1e66..000000000 --- a/app/features/sendouq/queries/addInitialSkill.server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ordinal } from "openskill"; -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - insert into "Skill" ("mu", "season", "sigma", "ordinal", "userId", "matchesCount") - values ( - @mu, - @season, - @sigma, - @ordinal, - @userId, - 0 - ) -`); - -export function addInitialSkill({ - mu, - sigma, - season, - userId, -}: { - mu: number; - sigma: number; - season: number; - userId: number; -}) { - stm.run({ - mu, - sigma, - season, - ordinal: ordinal({ mu, sigma }), - userId, - }); -} diff --git a/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts b/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts deleted file mode 100644 index 89642ac27..000000000 --- a/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { sql } from "~/db/sql"; -import * as Seasons from "~/features/mmr/core/Seasons"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; - -const stm = sql.prepare(/* sql */ ` - select - "ReportedWeapon"."weaponSplId", - count(*) as "count" - from - "ReportedWeapon" - left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId" - left join "GroupMatch" on "GroupMatch"."id" = "GroupMatchMap"."matchId" - where - "ReportedWeapon"."userId" = @userId - and "GroupMatch"."createdAt" between @starts and @ends - group by "ReportedWeapon"."weaponSplId" - order by "count" desc -`); - -export function seasonReportedWeaponsByUserId({ - userId, - season, -}: { - userId: number; - season: number; -}) { - const { starts, ends } = Seasons.nthToDateRange(season); - - return stm.all({ - userId, - starts: dateToDatabaseTimestamp(starts), - ends: dateToDatabaseTimestamp(ends), - }) as Array<{ weaponSplId: MainWeaponId; count: number }>; -} diff --git a/app/features/sendouq/queries/weaponUsageStats.server.ts b/app/features/sendouq/queries/weaponUsageStats.server.ts index 14b0b47b2..7a6b59a26 100644 --- a/app/features/sendouq/queries/weaponUsageStats.server.ts +++ b/app/features/sendouq/queries/weaponUsageStats.server.ts @@ -23,11 +23,13 @@ const stm = sql.prepare(/* sql */ ` ) as "weaponUserGroupId" from "GroupMember" left join "Group" on "Group"."id" = "GroupMember"."groupId" - inner join "GroupMatch" on - "GroupMatch"."alphaGroupId" = "Group"."id" + inner join "GroupMatch" on + "GroupMatch"."alphaGroupId" = "Group"."id" or "GroupMatch"."bravoGroupId" = "Group"."id" left join "GroupMatchMap" on "GroupMatchMap"."matchId" = "GroupMatch"."id" - inner join "ReportedWeapon" on "ReportedWeapon"."groupMatchMapId" = "GroupMatchMap"."id" + inner join "ReportedWeapon" + on "ReportedWeapon"."groupMatchId" = "GroupMatch"."id" + and "ReportedWeapon"."mapIndex" = "GroupMatchMap"."index" where "GroupMember"."userId" = @userId and "GroupMatch"."createdAt" between @starts and @ends diff --git a/app/features/sendouq/routes/q.tsx b/app/features/sendouq/routes/q.tsx index a23ac42f0..40f137eec 100644 --- a/app/features/sendouq/routes/q.tsx +++ b/app/features/sendouq/routes/q.tsx @@ -18,6 +18,7 @@ import { useUser } from "~/features/auth/core/user"; import type * as Seasons from "~/features/mmr/core/Seasons"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useHydrated } from "~/hooks/useHydrated"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import { useHasRole } from "~/modules/permissions/hooks"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -64,6 +65,7 @@ export const meta: MetaFunction = (args) => { export default function QPage() { const { t } = useTranslation(["q"]); + const { formatDateTime } = useTimeFormat(); const [dialogOpen, setDialogOpen] = React.useState(true); const user = useUser(); const data = useLoaderData(); @@ -132,9 +134,9 @@ export default function QPage() { > As a fresh account please wait before joining the queue. You can join{" "} - {queueJoinStatus.toLocaleString("en-US", { + {formatDateTime(queueJoinStatus, { day: "numeric", - month: "long", + month: "numeric", hour: "numeric", minute: "numeric", })} @@ -184,33 +186,11 @@ const countries = [ { id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "paris" }, { id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "tokyo" }, ] as const; -const weekdayFormatter = ({ - timeZone, - locale, -}: { - timeZone: string; - locale: string; -}) => - new Intl.DateTimeFormat([locale], { - timeZone, - weekday: "long", - }); -const clockFormatter = ({ - timeZone, - locale, -}: { - timeZone: string; - locale: string; -}) => - new Intl.DateTimeFormat([locale], { - timeZone, - hour: "numeric", - minute: "numeric", - }); function Clocks() { const isHydrated = useHydrated(); - const { t, i18n } = useTranslation(["q"]); - useAutoRerender(); + const { t } = useTranslation(["q"]); + const { formatDate, formatTime } = useTimeFormat(); + const now = useAutoRerender(); return (
@@ -223,19 +203,20 @@ function Clocks() {
{isHydrated - ? weekdayFormatter({ + ? formatDate(now, { timeZone: country.timeZone, - locale: i18n.language, - }).format(new Date()) + weekday: "long", + }) : // take space "Monday"}
{isHydrated - ? clockFormatter({ + ? formatTime(now, { timeZone: country.timeZone, - locale: i18n.language, - }).format(new Date()) + hour: "numeric", + minute: "numeric", + }) : // take space "0:00 PM"}
@@ -293,15 +274,16 @@ function ActiveSeasonInfo({ }: { season: SerializeFrom; }) { - const { t, i18n } = useTranslation(["q"]); + const { t } = useTranslation(["q"]); + const { formatDateTime } = useTimeFormat(); const isHydrated = useHydrated(); const starts = new Date(season.starts); const ends = new Date(season.ends); const dateToString = (date: Date) => - date.toLocaleString(i18n.language, { - month: "short", + formatDateTime(date, { + month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", @@ -401,14 +383,15 @@ function UpcomingSeasonInfo({ season: SerializeFrom; }) { const { t } = useTranslation(["q"]); + const { formatDateTime } = useTimeFormat(); const isHydrated = useHydrated(); if (!isHydrated) return null; const starts = new Date(season.starts); const dateToString = (date: Date) => - date.toLocaleString("en-US", { - month: "long", + formatDateTime(date, { + month: "numeric", day: "numeric", hour: "numeric", }); diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index f05cf873d..b1b7fd6ed 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -53,12 +53,31 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); break; } + case "UPDATE_NO_SPLATNET": { + await QSettingsRepository.updateNoSplatnet({ + userId: user.id, + noSplatnet: Number(data.newValue), + }); + break; + } case "UPDATE_CLOCK_FORMAT": { await UserRepository.updatePreferences(user.id, { clockFormat: data.newValue, }); break; } + case "UPDATE_WEAPON_REPORT_DEFAULT_OPEN": { + await UserRepository.updatePreferences(user.id, { + weaponReportDefaultOpen: data.newValue, + }); + break; + } + case "UPDATE_DATE_FORMAT": { + await UserRepository.updatePreferences(user.id, { + dateFormat: data.newValue, + }); + break; + } default: { assertUnreachable(data); } diff --git a/app/features/settings/loaders/settings.server.ts b/app/features/settings/loaders/settings.server.ts index 02f356d98..15e913061 100644 --- a/app/features/settings/loaders/settings.server.ts +++ b/app/features/settings/loaders/settings.server.ts @@ -8,5 +8,8 @@ export const loader = async () => { noScreen: user ? await UserRepository.anyUserPrefersNoScreen([user.id]) : null, + noSplatnet: user + ? await UserRepository.anyUserPrefersNoSplatnet([user.id]) + : null, }; }; diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index 1afedd124..a2574a1e6 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -30,10 +30,12 @@ import { action } from "../actions/settings.server"; import { loader } from "../loaders/settings.server"; import { clockFormatSchema, + dateFormatSchema, disableBuildAbilitySortingSchema, disallowScrimPickupsFromUntrustedSchema, spoilerFreeModeSchema, updateNoScreenSchema, + updateNoSplatnetSchema, } from "../settings-schemas"; import styles from "./settings.module.css"; import "./settings.global.css"; @@ -88,6 +90,18 @@ export default function SettingsPage() { {({ FormField }) => } ) : null} + {user ? ( + + {({ FormField }) => } + + ) : null} {user ? ( <> @@ -127,6 +141,16 @@ export default function SettingsPage() { > {({ FormField }) => } + + {({ FormField }) => } + { ShowcaseTournaments.upcomingTournaments(), ]); - const seenUsernames = new Set( - sendouQEntries.flatMap((e) => + const seenUsernames = new Set([ + ...getLiveTournamentStreamerTwitchNames(), + ...sendouQEntries.flatMap((e) => e.twitchUsernames.map((t) => t.toLowerCase()), ), - ); + ]); const ranked: { stream: SidebarStream; score: number }[] = []; diff --git a/app/features/team/components/TeamResultsTable.tsx b/app/features/team/components/TeamResultsTable.tsx index 70a506b0b..0d2223b2d 100644 --- a/app/features/team/components/TeamResultsTable.tsx +++ b/app/features/team/components/TeamResultsTable.tsx @@ -47,7 +47,7 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) { {formatDate(databaseTimestampToDate(result.startTime), { day: "numeric", - month: "short", + month: "numeric", year: "numeric", })} diff --git a/app/features/team/routes/t.$customUrl.results.tsx b/app/features/team/routes/t.$customUrl.results.tsx index 3529b36ca..35008b2f1 100644 --- a/app/features/team/routes/t.$customUrl.results.tsx +++ b/app/features/team/routes/t.$customUrl.results.tsx @@ -6,6 +6,10 @@ import { loader } from "../loaders/t.$customUrl.results.server"; export { loader }; +export const handle = { + i18n: ["user"], +}; + export default function TeamResultsPage() { const data = useLoaderData(); diff --git a/app/features/top-search/routes/xsearch.tsx b/app/features/top-search/routes/xsearch.tsx index 1dba5c76f..0b0e97ec7 100644 --- a/app/features/top-search/routes/xsearch.tsx +++ b/app/features/top-search/routes/xsearch.tsx @@ -4,6 +4,7 @@ import type { MetaFunction } from "react-router"; import { useLoaderData, useSearchParams } from "react-router"; import { Main } from "~/components/Main"; import type { Tables } from "~/db/tables"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { RankedModeShort } from "~/modules/in-game-lists/types"; import invariant from "~/utils/invariant"; @@ -37,6 +38,7 @@ export const meta: MetaFunction = (args) => { export default function XSearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(["common", "game-misc"]); + const { formatDate } = useTimeFormat(); const data = useLoaderData(); const handleSelectChange = (event: React.ChangeEvent) => { @@ -60,6 +62,12 @@ export default function XSearchPage() { searchParams.get("mode") ?? "SZ" }-${searchParams.get("region") ?? "WEST"}`; + const formatMonthYear = (my: MonthYear) => + formatDate(new Date(my.year, my.month - 1), { + month: "numeric", + year: "numeric", + }); + return (
- {castTwitchAccounts.map((account) => ( - - ))} - - ) : ( - - )} - - ); - } - - // if for some reason match is locked in the DB but also has scores reported then the UI - // will act as if it's not locked at all - if (!matchIsOngoing && isLocked) { - return ( - } - infoText={lockingInfo} - /> - ); - } - - return ( - - - - ); -} - -function CastInfoWrapper({ - children, - icon, - submitButtonText, - _action, - infoText, -}: { - children?: React.ReactNode; - icon?: JSX.Element; - submitButtonText?: string; - _action?: string; - infoText?: string; -}) { - const fetcher = useFetcher(); - - return ( -
- -
Cast
- -
- {children ? ( -
{children}
- ) : null} - {submitButtonText && _action ? ( - - {submitButtonText} - - ) : null} -
-
- {infoText ? {infoText} : null} -
- ); -} diff --git a/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx b/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx deleted file mode 100644 index 393832dbb..000000000 --- a/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import clsx from "clsx"; -import { differenceInSeconds } from "date-fns"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { InfoPopover } from "~/components/InfoPopover"; -import * as Deadline from "../core/Deadline"; -import styles from "../tournament-bracket.module.css"; - -interface DeadlineInfoPopoverProps { - startedAt: Date; - bestOf: number; - gamesCompleted: number; -} - -export function DeadlineInfoPopover({ - startedAt, - bestOf, - gamesCompleted, -}: DeadlineInfoPopoverProps) { - const { t } = useTranslation(["tournament"]); - const [currentTime, setCurrentTime] = React.useState(new Date()); - - React.useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date()); - }, 5_000); - - return () => clearInterval(interval); - }, []); - - const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60; - - const status = Deadline.matchStatus({ - elapsedMinutes, - gamesCompleted, - maxGamesCount: bestOf, - }); - - const warningIndicator = - status === "warning" ? ( - - ! - - ) : status === "error" ? ( - - ! - - ) : null; - - return ( -
- - {t("tournament:match.deadline.explanation")} - - {warningIndicator} -
- ); -} diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx deleted file mode 100644 index 3247d1c09..000000000 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { SquarePen } from "lucide-react"; -import * as React from "react"; -import { Form, useFetcher, useLoaderData } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; -import invariant from "~/utils/invariant"; -import * as PickBan from "../core/PickBan"; -import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import styles from "../tournament-bracket.module.css"; -import { - isSetOverByScore, - matchIsLocked, - tournamentTeamToActiveRosterUserIds, -} from "../tournament-bracket-utils"; -import { MatchActionsBanPicker } from "./MatchActionsBanPicker"; -import type { Result } from "./StartedMatch"; -import { TeamRosterInputs } from "./TeamRosterInputs"; - -export function MatchActions({ - teams, - position, - result, - scores, - presentational: _presentational, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; - position: number; - result?: Result; - scores: [number, number]; - presentational?: boolean; -}) { - const user = useUser(); - const tournament = useTournament(); - const data = useLoaderData(); - - const [checkedPlayers, setCheckedPlayers] = React.useState< - [number[], number[]] - >(() => { - if (result) { - return [ - result.participants - .filter((participant) => - teams[0].members.some( - (member) => - member.userId === participant.userId && - (!participant.tournamentTeamId || - teams[0].id === participant.tournamentTeamId), - ), - ) - .map((p) => p.userId), - result.participants - .filter((participant) => - teams[1].members.some( - (member) => - member.userId === participant.userId && - (!participant.tournamentTeamId || - teams[1].id === participant.tournamentTeamId), - ), - ) - .map((p) => p.userId), - ]; - } - - return [ - tournamentTeamToActiveRosterUserIds( - teams[0], - tournament.minMembersPerTeam, - ) ?? [], - tournamentTeamToActiveRosterUserIds( - teams[1], - tournament.minMembersPerTeam, - ) ?? [], - ]; - }); - - const [winnerId, setWinnerId] = React.useState(); - const [points, setPoints] = React.useState<[number, number]>( - typeof result?.opponentOnePoints === "number" && - typeof result.opponentTwoPoints === "number" - ? [result.opponentOnePoints, result.opponentTwoPoints] - : [0, 0], - ); - const [revising, setRevising] = React.useState(false); - - const presentational = !revising && (_presentational || Boolean(result)); - - const newScore: [number, number] = [ - scores[0] + (winnerId === teams[0].id ? 1 : 0), - scores[1] + (winnerId === teams[1].id ? 1 : 0), - ]; - const wouldEndSet = isSetOverByScore({ - count: data.match.roundMaps.count, - countType: data.match.roundMaps?.type ?? "BEST_OF", - scores: newScore, - }); - - const showPoints = React.useMemo( - () => - tournament.bracketByIdxOrDefault( - tournament.matchIdToBracketIdx(data.match.id) ?? 0, - ).collectResultsWithPoints, - [tournament, data.match.id], - ); - - const bothTeamsHaveActiveRosters = teams.every((team) => - tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam), - ); - - const turnOf = - data.match.roundMaps && - PickBan.turnOf({ - results: data.results, - maps: data.match.roundMaps, - teams: [ - { id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed }, - { id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed }, - ], - mapList: data.mapList, - pickBanEventCount: data.pickBanEventCount, - }); - - if (turnOf && bothTeamsHaveActiveRosters) { - return ( - - ); - } - - const canEditFinishedSet = - result && tournament.isOrganizer(user) && !tournament.ctx.isFinalized; - - return ( -
- - {!presentational && bothTeamsHaveActiveRosters ? ( -
- - {showPoints ? ( - - ) : null} - - {!revising && ( - - )} - - ) : null} - {canEditFinishedSet ? ( - - teamMembers.length !== tournament.minMembersPerTeam, - )} - /> - ) : null} - {!result && presentational ? ( -
-

- No permissions to report score -

-
- ) : null} -
- ); - - function winnerOfSetName() { - if (!winnerId) return; - - const setWinningIdx = newScore[0] > newScore[1] ? 0 : 1; - - const result = teams[setWinningIdx].name; - invariant(result, "No set winning team"); - - return result; - } - - function winningTeamIdx() { - if (!winnerId) return; - if (teams[0].id === winnerId) return 0; - if (teams[1].id === winnerId) return 1; - - throw new Error("No winning team matching the id"); - } -} - -function ReportScoreButtons({ - points, - winnerIdx, - winnerOfSetName, - wouldEndSet, - matchLocked, - newScore, -}: { - points?: [number, number]; - winnerIdx?: number; - winnerOfSetName?: string; - wouldEndSet: boolean; - matchLocked: boolean; - newScore: [number, number]; -}) { - const data = useLoaderData(); - const user = useUser(); - const tournament = useTournament(); - const confirmCheckId = React.useId(); - const pointConfirmCheckId = React.useId(); - const [endConfirmation, setEndConfirmation] = React.useState(false); - const [pointConfirmation, setPointConfirmation] = React.useState(false); - - if (isLeagueRoundLocked(tournament, data.match.roundId)) { - return ( -

- League round has not started yet -

- ); - } - - if (matchLocked) { - return ( -

- Match is pending to be casted. Please wait a bit -

- ); - } - - if ( - points && - typeof winnerIdx === "number" && - points[winnerIdx] <= points[winnerIdx === 0 ? 1 : 0] - ) { - return ( -

- Winner should have higher score than loser -

- ); - } - - if ( - points && - ((points[0] === 100 && points[1] !== 0) || - (points[0] !== 0 && points[1] === 100)) - ) { - return ( -

- If there was a KO (100 score), other team should have 0 score -

- ); - } - - if (typeof winnerIdx !== "number") { - return ( -

- Please select the winner of this map -

- ); - } - - const confirmationClass = () => { - const ownTeam = tournament.teamMemberOfByUser(user); - - // TO reporting - if (!ownTeam) return "text-main-forced"; - if (ownTeam.name === winnerOfSetName) return "text-success"; - - return "text-warning"; - }; - - const lowPoints = points?.every((point) => point < 10); - const submitButtonDisabled = () => { - if (wouldEndSet && !endConfirmation) return true; - if (lowPoints && !pointConfirmation) return true; - - return false; - }; - - return ( -
- {wouldEndSet ? ( -
- setEndConfirmation(e.target.checked)} - id={confirmCheckId} - data-testid="end-confirmation" - /> - -
- ) : null} - {lowPoints ? ( -
- setPointConfirmation(e.target.checked)} - id={pointConfirmCheckId} - /> - -
- ) : null} - - {wouldEndSet ? "Report & end set" : "Report"} - -
- ); -} - -function EditScoreForm({ - editing, - setEditing, - checkedPlayers, - resultId, - points, - submitDisabled, -}: { - editing: boolean; - setEditing: (value: boolean) => void; - checkedPlayers: [number[], number[]]; - resultId: number; - points?: [number, number]; - submitDisabled: boolean; -}) { - const fetcher = useFetcher(); - - if (editing) { - return ( - - - - {points ? ( - - ) : undefined} - - Save - - setEditing(false)} - > - Cancel - - - ); - } - - return ( -
- } - variant="outlined" - size="small" - className="mx-auto" - onPress={() => setEditing(true)} - data-testid="revise-button" - > - Edit - -
- ); -} diff --git a/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css b/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css deleted file mode 100644 index 1abe61f07..000000000 --- a/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css +++ /dev/null @@ -1,89 +0,0 @@ -.mapPoolPicker { - --map-width: 90px; - --map-height: 50px; -} - -.divider { - font-size: var(--font-xs); - font-weight: var(--weight-semi); - text-transform: uppercase; - display: flex; - gap: var(--s-2); - - &::before, - &::after { - border-bottom: 2px dotted var(--color-bg-higher); - } -} - -.mapButton { - background-image: var(--map-image-url); - background-size: cover; - height: var(--map-height); - width: var(--map-width); - border: none; - background-color: transparent; - transition: - filter, - opacity 0.2s; - border-radius: var(--radius-box); - - &:active { - transform: none; - } -} - -.mapButtonGreyedOut { - filter: grayscale(100%) !important; - opacity: 0.4 !important; -} - -.mapButtonIcon { - position: absolute; - top: 2px; - color: var(--color-success); - width: 48px; - height: 48px; - cursor: pointer; -} - -.mapButtonIconError { - color: var(--color-error); -} - -.mapButtonIconMuted { - color: var(--color-text-high); -} - -.mapButtonNumber { - position: absolute; - background-color: var(--color-text-accent); - border-radius: 100%; - width: 18px; - height: 18px; - display: grid; - place-items: center; - color: var(--color-text-inverse); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - top: -5px; - left: 0; -} - -.mapButtonFrom { - position: absolute; - bottom: -15px; - font-size: var(--font-xs); - font-weight: var(--weight-bold); -} - -.mapButtonContainer { - width: var(--map-width); - text-align: center; -} - -.mapButtonLabel { - font-size: var(--font-2xs); - color: var(--color-text-high); - font-weight: var(--weight-semi); -} diff --git a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx deleted file mode 100644 index af4101881..000000000 --- a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import clsx from "clsx"; -import { Check, X } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useFetcher, useLoaderData } from "react-router"; -import { Divider } from "~/components/Divider"; -import { ModeImage, StageImage } from "~/components/Image"; -import { SubmitButton } from "~/components/SubmitButton"; -import type { ActionType, TournamentRoundMaps } from "~/db/tables"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { modesShort } from "~/modules/in-game-lists/modes"; -import { shortStageName } from "~/modules/in-game-lists/stage-ids"; -import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import invariant from "~/utils/invariant"; -import { stageImageUrl } from "~/utils/urls"; -import * as PickBan from "../core/PickBan"; -import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import styles from "./MatchActionsBanPicker.module.css"; - -/** stageId is omitted for mode-only actions (MODE_PICK / MODE_BAN) where no specific stage is selected */ -type BanPickerSelection = { - mode: ModeShort; - stageId?: StageId; -}; - -export function MatchActionsBanPicker({ - teams, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - const data = useLoaderData(); - const tournament = useTournament(); - const maps = data.match.roundMaps!; - const [selected, setSelected] = React.useState(); - - const turnOfResult = PickBan.turnOf({ - results: data.results, - maps, - teams: [ - { id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed }, - { id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed }, - ], - mapList: data.mapList, - pickBanEventCount: data.pickBanEventCount, - })!; - const pickerTeamId = turnOfResult.teamId; - const pickingTeam = teams.find((team) => team.id === pickerTeamId)!; - - const actionType = turnOfResult.action; - const isModePick = actionType === "MODE_PICK"; - const isModeBan = actionType === "MODE_BAN"; - const isModeAction = isModePick || isModeBan; - - return ( -
- {isModeAction ? ( - - ) : ( - - )} - -
- ); -} - -function MapPicker({ - selected, - setSelected, - pickerTeamId, - teams, - actionType, -}: { - selected?: BanPickerSelection; - setSelected: (selected: BanPickerSelection) => void; - pickerTeamId: number; - teams: [TournamentDataTeam, TournamentDataTeam]; - actionType: ActionType; -}) { - const user = useUser(); - const data = useLoaderData(); - const tournament = useTournament(); - - const pickBanMapPool = PickBan.mapsListWithLegality({ - toSetMapPool: tournament.ctx.toSetMapPool, - maps: data.match.roundMaps, - mapList: data.mapList, - teams, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, - pickerTeamId, - results: data.results, - pickBanEvents: data.pickBanEvents, - }); - - const modes = modesShort.filter((mode) => - pickBanMapPool.some((map) => map.mode === mode && map.isLegal), - ); - - const canPickBan = - tournament.isOrganizer(user) || - tournament.ownedTeamByUser(user)?.id === pickerTeamId; - - const teamMemberOf = tournament.teamMemberOfByUser(user); - const isPartOfTheMatch = teams.some((t) => t.id === teamMemberOf?.id); - const mapFromWhere = (stageId: StageId, mode: ModeShort) => { - if (!isPartOfTheMatch) { - return; - } - - const teamOneHas = teams[0].mapPool?.some( - (map) => map.stageId === stageId && map.mode === mode, - ); - const teamTwoHas = teams[1].mapPool?.some( - (map) => map.stageId === stageId && map.mode === mode, - ); - - if (teamOneHas && teamTwoHas) { - return "BOTH"; - } - - if (teamOneHas) { - return teams[0].id === teamMemberOf?.id ? "US" : "THEM"; - } - - if (teamTwoHas) { - return teams[1].id === teamMemberOf?.id ? "US" : "THEM"; - } - - return; - }; - - const pickersLastWonMode = data.results - .slice() - .reverse() - .find((result) => result.winnerTeamId === pickerTeamId)?.mode; - - return ( -
- {modes.map((mode) => { - const stages = pickBanMapPool - .filter((map) => map.mode === mode) - .sort((a, b) => a.stageId - b.stageId); - - return ( -
- - - -
- {stages.map(({ stageId, isLegal }) => { - const number = - data.match.roundMaps?.pickBan === "BAN_2" - ? (data.mapList ?? [])?.findIndex( - (m) => m.stageId === stageId && m.mode === mode, - ) + 1 - : undefined; - - return ( - setSelected({ mode, stageId }) - : undefined - } - number={number} - from={mapFromWhere(stageId, mode)} - /> - ); - })} -
- {data.match.roundMaps?.pickBan !== "CUSTOM" && - pickersLastWonMode === mode && - modes.length > 1 ? ( -
- Can't pick the same mode team last won on -
- ) : null} -
- ); - })} -
- ); -} - -function MapButton({ - stageId, - onClick, - selected, - disabled, - actionType, - number, - from, -}: { - stageId: StageId; - onClick?: () => void; - selected?: boolean; - disabled?: boolean; - actionType?: ActionType; - number?: number; - from?: "US" | "THEM" | "BOTH"; -}) { - const { t } = useTranslation(["game-misc"]); - - return ( -
-
- ); -} - -function ModePicker({ - selected, - setSelected, - pickerTeamId, - teams, -}: { - selected?: BanPickerSelection; - setSelected: (selected: BanPickerSelection) => void; - pickerTeamId: number; - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - const user = useUser(); - const data = useLoaderData(); - const tournament = useTournament(); - const { t } = useTranslation(["game-misc"]); - - const pickBanMapPool = PickBan.mapsListWithLegality({ - toSetMapPool: tournament.ctx.toSetMapPool, - maps: data.match.roundMaps, - mapList: data.mapList, - teams, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, - pickerTeamId, - results: data.results, - pickBanEvents: data.pickBanEvents, - }); - - const availableModes = modesShort.filter((mode) => - pickBanMapPool.some((map) => map.mode === mode && map.isLegal), - ); - - const canPickBan = - tournament.isOrganizer(user) || - tournament.ownedTeamByUser(user)?.id === pickerTeamId; - - return ( -
- {availableModes.map((mode) => ( - - ))} -
- ); -} - -function CounterpickSubmitter({ - selected, - pickingTeam, - pickBan, - actionType, -}: { - selected?: BanPickerSelection; - pickingTeam: TournamentDataTeam; - pickBan: NonNullable; - actionType: ActionType; -}) { - const fetcher = useFetcher(); - const { t } = useTranslation(["game-misc"]); - const user = useUser(); - const tournament = useTournament(); - - const ownedTeam = tournament.ownedTeamByUser(user); - - const picking = - tournament.isOrganizer(user) || ownedTeam?.id === pickingTeam.id; - - const isModeAction = actionType === "MODE_PICK" || actionType === "MODE_BAN"; - - const isCustom = pickBan === "CUSTOM"; - - const actionLabel = () => { - if (actionType === "BAN" || pickBan === "BAN_2") return "Ban"; - if (actionType === "MODE_PICK") return "Pick mode"; - if (actionType === "MODE_BAN") return "Ban mode"; - if (isCustom) return "Pick"; - return "Counterpick"; - }; - - const promptLabel = () => { - if (actionType === "BAN" || pickBan === "BAN_2") { - return "Please select your team's ban above"; - } - if (actionType === "MODE_PICK") return "Please select a mode to pick above"; - if (actionType === "MODE_BAN") return "Please select a mode to ban above"; - if (isCustom) return "Please select your team's pick above"; - return "Please select your team's counterpick above"; - }; - - if (!picking) { - return ( -
- Waiting for captain of {pickingTeam.name} to make their selection -
- ); - } - - if (picking && !selected) { - return ( -
- {promptLabel()} -
- ); - } - - invariant(selected, "CounterpickSubmitter: selected is undefined"); - - const stageId = isModeAction ? null : selected.stageId; - invariant(isModeAction || typeof stageId === "number", "Expected stageId"); - - return ( -
-
- {actionLabel()}: {t(`game-misc:MODE_SHORT_${selected.mode}`)} - {typeof stageId === "number" - ? ` ${t(`game-misc:STAGE_${stageId}`)}` - : null} -
-
- - {typeof stageId === "number" ? ( - - ) : null} -
- - {typeof stageId === "number" ? ( - - ) : null} - - Confirm - -
- ); -} diff --git a/app/features/tournament-bracket/components/MatchMapInfo.module.css b/app/features/tournament-bracket/components/MatchMapInfo.module.css deleted file mode 100644 index 671e67744..000000000 --- a/app/features/tournament-bracket/components/MatchMapInfo.module.css +++ /dev/null @@ -1,54 +0,0 @@ -.container { - padding: var(--s-2); -} - -.section { - display: flex; - flex-direction: column; - gap: var(--s-1-5); -} - -.maps { - display: flex; - flex-wrap: wrap; - gap: var(--s-2); -} - -.mapEntry { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--s-0-5); - width: 90px; -} - -.stageImage { - border-radius: var(--radius-box); -} - -.mapLabel { - font-size: var(--font-2xs); - color: var(--color-text-high); - font-weight: var(--weight-semi); - text-align: center; -} - -.modeEntry { - display: flex; - align-items: center; - gap: var(--s-1); - font-size: var(--font-sm); - font-weight: var(--weight-semi); -} - -.heading { - font-size: var(--font); - font-weight: var(--weight-bold); - margin-block: 0 var(--s-2); -} - -.emptyText { - font-size: var(--font-sm); - color: var(--color-text-high); - font-style: italic; -} diff --git a/app/features/tournament-bracket/components/MatchMapInfo.tsx b/app/features/tournament-bracket/components/MatchMapInfo.tsx deleted file mode 100644 index a53055e93..000000000 --- a/app/features/tournament-bracket/components/MatchMapInfo.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { useLoaderData } from "react-router"; -import { ModeImage, StageImage } from "~/components/Image"; -import type { CustomPickBanStep } from "~/db/tables"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import styles from "./MatchMapInfo.module.css"; - -export function MatchMapInfo({ teams }: { teams: [number, number] }) { - const data = useLoaderData(); - const tournament = useTournament(); - - const teamOne = tournament.teamById(teams[0]); - const teamTwo = tournament.teamById(teams[1]); - - const customFlow = data.match.roundMaps?.customFlow; - if (!customFlow) return null; - - const pickBanTeams: [PickBan.PickBanTeam, PickBan.PickBanTeam] = [ - { id: teams[0], seed: teamOne?.seed ?? 0 }, - { id: teams[1], seed: teamTwo?.seed ?? 0 }, - ]; - - const teamOneBans: BanEvent[] = []; - const teamTwoBans: BanEvent[] = []; - - for (let i = 0; i < data.pickBanEvents.length; i++) { - const event = data.pickBanEvents[i]!; - if (event.type !== "BAN" && event.type !== "MODE_BAN") continue; - - const teamId = resolveTeamForEvent({ - eventIndex: i, - preSet: customFlow.preSet, - postGame: customFlow.postGame, - teams: pickBanTeams, - results: data.results, - }); - - if (teamId === teams[0]) { - teamOneBans.push(event); - } else if (teamId === teams[1]) { - teamTwoBans.push(event); - } - } - - return ( -
-
- - - -
-
- ); -} - -function resolveTeamForEvent({ - eventIndex, - preSet, - postGame, - teams, - results, -}: { - eventIndex: number; - preSet: CustomPickBanStep[]; - postGame: CustomPickBanStep[]; - teams: [PickBan.PickBanTeam, PickBan.PickBanTeam]; - results: Array<{ winnerTeamId: number }>; -}): number | null { - const step = - eventIndex < preSet.length - ? preSet[eventIndex] - : postGame[(eventIndex - preSet.length) % postGame.length]; - - if (!step?.side) return null; - - // PickBan.resolveTeamFromSide uses the last element of results for WINNER/LOSER, - // but here we iterate over all historical events so we need to slice - // results to the correct post-game cycle - if (step.side === "WINNER" || step.side === "LOSER") { - const cycleIndex = Math.floor( - (eventIndex - preSet.length) / postGame.length, - ); - if (!results[cycleIndex]) return null; - - return PickBan.resolveTeamFromSide({ - side: step.side, - teams, - results: results.slice(0, cycleIndex + 1), - }); - } - - return PickBan.resolveTeamFromSide({ - side: step.side, - teams, - results, - }); -} - -interface BanEvent { - stageId: StageId | null; - mode: ModeShort | null; - type: string; -} - -function BanSection({ - teamName, - bans, -}: { - teamName: string; - bans: BanEvent[]; -}) { - const { t } = useTranslation(["game-misc", "tournament"]); - const mapBans = bans.filter( - (b): b is BanEvent & { stageId: StageId; mode: ModeShort } => - b.type === "BAN" && b.stageId !== null && b.mode !== null, - ); - const modeBans = bans.filter( - (b): b is BanEvent & { mode: ModeShort } => - b.type === "MODE_BAN" && b.mode !== null, - ); - - return ( -
-

- {t("tournament:match.mapInfo.bans", { teamName })} -

- {mapBans.length === 0 && modeBans.length === 0 ? ( -
- {t("tournament:match.mapInfo.noBans")} -
- ) : null} - {mapBans.length > 0 ? ( -
- {mapBans.map((ban, i) => ( - - ))} -
- ) : null} - {modeBans.length > 0 ? ( -
- {modeBans.map((ban, i) => ( -
- - {t(`game-misc:MODE_LONG_${ban.mode}`)} -
- ))} -
- ) : null} -
- ); -} - -function PlayedSection({ - results, -}: { - results: Array<{ stageId: StageId; mode: ModeShort }>; -}) { - const { t } = useTranslation(["game-misc", "tournament"]); - - return ( -
-

- {t("tournament:match.mapInfo.playedStages")} -

- {results.length === 0 ? ( -
- {t("tournament:match.mapInfo.noPlayedStages")} -
- ) : ( -
- {results.map((result, i) => ( - - ))} -
- )} -
- ); -} - -function MapEntry({ stageId, mode }: { stageId: StageId; mode?: ModeShort }) { - const { t } = useTranslation(["game-misc"]); - - return ( -
- -
- {mode ? `${t(`game-misc:MODE_SHORT_${mode}`)} ` : null} - {t(`game-misc:STAGE_${stageId}`).split(" ")[0]} -
-
- ); -} diff --git a/app/features/tournament-bracket/components/MatchRosters.tsx b/app/features/tournament-bracket/components/MatchRosters.tsx deleted file mode 100644 index edfb192f0..000000000 --- a/app/features/tournament-bracket/components/MatchRosters.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import clsx from "clsx"; -import { Link, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { tournamentTeamPage, userPage } from "~/utils/urls"; -import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import styles from "../tournament-bracket.module.css"; - -export function MatchRosters({ - teams, -}: { - teams: [id: number | null | undefined, id: number | null | undefined]; -}) { - const data = useLoaderData(); - const tournament = useTournament(); - - const teamOne = teams[0] ? tournament.teamById(teams[0]) : undefined; - const teamTwo = teams[1] ? tournament.teamById(teams[1]) : undefined; - const teamOnePlayers = data.match.players.filter( - (p) => p.tournamentTeamId === teamOne?.id, - ); - const teamTwoPlayers = data.match.players.filter( - (p) => p.tournamentTeamId === teamTwo?.id, - ); - - const teamOneParticipatedPlayers = teamOnePlayers.filter((p) => - tournament.ctx.participatedUsers.includes(p.id), - ); - const teamTwoParticipatedPlayers = teamTwoPlayers.filter((p) => - tournament.ctx.participatedUsers.includes(p.id), - ); - - const teamOneLogoSrc = teamOne - ? tournament.tournamentTeamLogoSrc(teamOne) - : null; - const teamTwoLogoSrc = teamTwo - ? tournament.tournamentTeamLogoSrc(teamTwo) - : null; - - return ( -
-
-
-
- Team 1 -
-

- {teamOne ? ( - - - {teamOne.name} - - ) : ( - "Waiting on team" - )} -

- {teamOnePlayers.length > 0 ? ( -
    - {teamOnePlayers.map((p) => { - const isInactive = - teamOneParticipatedPlayers.length > 0 && - teamOneParticipatedPlayers.every( - (participatedPlayer) => p.id !== participatedPlayer.id, - ); - - return ( -
  • - - - {p.username} - {p.pronouns ? ( - - {p.pronouns.subject}/{p.pronouns.object} - - ) : null} - -
  • - ); - })} -
- ) : null} -
-
-
-
- Team 2 -
-

- {teamTwo ? ( - - - {teamTwo.name} - - ) : ( - "Waiting on team" - )} -

- {teamTwoPlayers.length > 0 ? ( -
    - {teamTwoPlayers.map((p) => { - const isInactive = - teamTwoParticipatedPlayers.length > 0 && - teamTwoParticipatedPlayers.every( - (participatedPlayer) => p.id !== participatedPlayer.id, - ); - - return ( -
  • - - - {p.username} - {p.pronouns ? ( - - {p.pronouns.subject}/{p.pronouns.object} - - ) : null} - -
  • - ); - })} -
- ) : null} -
-
- ); -} diff --git a/app/features/tournament-bracket/components/MatchTimer.module.css b/app/features/tournament-bracket/components/MatchTimer.module.css deleted file mode 100644 index d7adc8d4e..000000000 --- a/app/features/tournament-bracket/components/MatchTimer.module.css +++ /dev/null @@ -1,65 +0,0 @@ -.progressContainer { - position: relative; - width: 100%; - height: 18px; - background-color: var(--color-bg); -} - -.progressBar { - position: absolute; - left: 0; - top: 0; - height: 100%; - background-color: var(--color-text-accent); - transition: - width 0.5s ease-in-out, - background-color 0.3s ease; - z-index: 1; -} - -.gameMarker { - position: absolute; - top: 0; - height: 100%; - display: flex; - flex-direction: row; - align-items: center; - gap: var(--s-1); - z-index: 2; - pointer-events: none; - transform: translateX(-50%); -} - -.gameMarkerLine { - width: 2px; - height: 100%; - background-color: var(--color-text); - opacity: 0.6; -} - -.maxTimeMarker { - position: absolute; - right: 0; - top: 0; - height: 100%; - display: flex; - flex-direction: row; - align-items: center; - gap: var(--s-1); - z-index: 2; - pointer-events: none; - padding-right: var(--s-1); -} - -.gameMarkerText { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - white-space: nowrap; - text-shadow: 0 0 3px var(--color-text-inverse); -} - -.gameMarkerHidden { - & .gameMarkerText { - visibility: hidden; - } -} diff --git a/app/features/tournament-bracket/components/MatchTimer.tsx b/app/features/tournament-bracket/components/MatchTimer.tsx deleted file mode 100644 index a86304193..000000000 --- a/app/features/tournament-bracket/components/MatchTimer.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { clsx } from "clsx"; -import { differenceInSeconds } from "date-fns"; -import * as React from "react"; -import * as Deadline from "../core/Deadline"; -import styles from "./MatchTimer.module.css"; - -interface MatchTimerProps { - startedAt: Date; - bestOf: number; -} - -export function MatchTimer({ startedAt, bestOf }: MatchTimerProps) { - const [currentTime, setCurrentTime] = React.useState(new Date()); - - React.useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date()); - }, 5_000); - - return () => clearInterval(interval); - }, []); - - const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60; - - const totalMinutes = Deadline.totalMatchTime(bestOf); - const progressPercentage = Deadline.progressPercentage( - elapsedMinutes, - totalMinutes, - ); - const gameMarkers = Deadline.gameMarkers(bestOf); - - return ( -
-
-
- - {gameMarkers.map((marker) => ( -
-
- G{marker.gameNumber} -
-
-
- Start -
-
- ))} - -
-
- Max -
-
- {totalMinutes}min -
-
-
-
- ); -} diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx deleted file mode 100644 index 965a3cb37..000000000 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ /dev/null @@ -1,886 +0,0 @@ -import clsx from "clsx"; -import { differenceInMinutes } from "date-fns"; -import type { TFunction } from "i18next"; -import { Check, MousePointerClick, X } from "lucide-react"; -import type { JSX } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Form, useLoaderData } from "react-router"; -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 { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { - isLeagueRoundLocked, - resolveLeagueRoundStartDate, -} from "~/features/tournament/tournament-utils"; -import { useHydrated } from "~/hooks/useHydrated"; -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/types"; -import { nullFilledArray } from "~/utils/arrays"; -import { databaseTimestampToDate } from "~/utils/dates"; -import type { SerializeFrom } from "~/utils/remix"; -import type { Unpacked } from "~/utils/types"; -import { - modeImageUrl, - specialWeaponImageUrl, - stageImageUrl, -} from "~/utils/urls"; -import type { Bracket } from "../core/Bracket"; -import * as Deadline from "../core/Deadline"; -import * as PickBan from "../core/PickBan"; -import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import styles from "../tournament-bracket.module.css"; -import { - groupNumberToLetters, - mapCountPlayedInSetWithCertainty, - matchIsLocked, - pickInfoText, - resolveHostingTeam, - resolveRoomPass, - tournamentTeamToActiveRosterUserIds, -} from "../tournament-bracket-utils"; -import { DeadlineInfoPopover } from "./DeadlineInfoPopover"; -import { MatchActions } from "./MatchActions"; -import { MatchMapInfo } from "./MatchMapInfo"; -import { MatchRosters } from "./MatchRosters"; -import { MatchTimer } from "./MatchTimer"; - -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 isHydrated = useHydrated(); - 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 waitingForPreviousMatch = data.match.status === 0; - - 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]); - - const roundInfos = [ - showFullInfos ? ( - - {t("tournament:match.hosts", { - teamName: resolveHostingTeam(teams).name, - })} - - ) : null, - showFullInfos ? ( - - {t("tournament:match.pass")}{" "} - - {resolveRoomPass(hostingTeamId)} - - - ) : 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 && - typeof data.noScreen === "boolean" ? ( - - ) : 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")} - -
-
- )} - {tournament.isOrganizer(user) && - !data.matchIsOver && - data.match.startedAt && - Deadline.matchStatus({ - elapsedMinutes: differenceInMinutes( - new Date(), - databaseTimestampToDate(data.match.startedAt), - ), - gamesCompleted: scoreOne + scoreTwo, - maxGamesCount: data.match.bestOf, - }) === "error" ? ( - - ) : null} -
- - {!waitingForPreviousMatch && (type === "EDIT" || presentational) ? ( - - ) : null} - {result ? ( -
- {isHydrated - ? databaseTimestampToDate(result.createdAt).toLocaleString() - : "t"} -
- ) : null} -
- ); -} - -function FancyStageBanner({ - stage, - infos, - children, - teams, - matchIsLocked, - waitingForPreviousMatch, -}: { - stage?: TournamentMapListMap; - infos?: (JSX.Element | null)[]; - children?: React.ReactNode; - teams: [TournamentDataTeam, TournamentDataTeam]; - matchIsLocked: boolean; - waitingForPreviousMatch: boolean; -}) { - const user = useUser(); - const data = useLoaderData(); - const { t } = useTranslation(["game-misc", "tournament"]); - const tournament = useTournament(); - - const gamesCompleted = data.results.length; - - const stageNameToBannerImageUrl = (stageId: StageId) => { - return `${stageImageUrl(stageId)}.avif`; - }; - - const turnOfResult = (() => { - if ( - !data.match.roundMaps || - !data.match.opponentOne?.id || - !data.match.opponentTwo?.id - ) { - return null; - } - - return PickBan.turnOf({ - results: data.results, - maps: data.match.roundMaps, - teams: [ - { - id: data.match.opponentOne.id, - seed: tournament.teamById(data.match.opponentOne.id)!.seed, - }, - { - id: data.match.opponentTwo.id, - seed: tournament.teamById(data.match.opponentTwo.id)!.seed, - }, - ], - mapList: data.mapList, - pickBanEventCount: data.pickBanEventCount, - }); - })(); - - const banPickingTeam = () => { - return turnOfResult - ? teams.find((t) => t.id === turnOfResult.teamId) - : null; - }; - - const style = { - "--_tournament-bg-url": stage - ? `url("${stageNameToBannerImageUrl(stage.stageId)}")` - : undefined, - }; - - const inBanPhase = - !data.matchIsOver && - 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 = isLeagueRoundLocked( - tournament, - data.match.roundId, - ); - - const noStageHeading = () => { - if (data.match.roundMaps?.pickBan === "CUSTOM" && turnOfResult) { - const stepCounter = - turnOfResult.stepTotal && turnOfResult.stepTotal > 1 - ? ` (${turnOfResult.stepCurrent}/${turnOfResult.stepTotal})` - : ""; - - switch (turnOfResult.action) { - case "PICK": - return t("tournament:pickBan.pickMap") + stepCounter; - case "BAN": - return t("tournament:pickBan.banMap") + stepCounter; - case "MODE_PICK": - return t("tournament:pickBan.pickMode") + stepCounter; - case "MODE_BAN": - return t("tournament:pickBan.banMode") + stepCounter; - default: - return t("tournament:pickBan.counterpick"); - } - } - return t("tournament:pickBan.counterpick"); - }; - - return ( - <> - {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 -
-
-
- ) : waitingForPreviousMatch ? ( -
-
-
- Previous match ongoing -
-
- Match will be reportable when both teams are ready to play -
-
-
- ) : waitingForActiveRosterSelectionFor ? ( -
-
-
- Active rosters need to be selected -
-
- Waiting on{" "} - {waitingForActiveRosterSelectionFor === "BOTH" - ? "both teams" - : waitingForActiveRosterSelectionFor} -
-
- {data.match.startedAt && - !tournament.isLeagueDivision && - (waitingForActiveRosterSelectionFor || !stage || inBanPhase) ? ( - - ) : null} -
- ) : inBanPhase ? ( -
-
-
Banning phase
-
Waiting for {banPickingTeam()?.name}
-
-
- ) : !stage ? ( -
-
-
- {noStageHeading()} -
-
Waiting for {banPickingTeam()?.name}
- {children} -
-
- ) : ( -
-
-

- - - {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, - })} -

-
- {data.match.startedAt && - !tournament.isLeagueDivision && - !data.matchIsOver ? ( - - ) : null} - {children} -
- )} - {(tournament.isOrganizer(user) || - teams.some((t) => t.members.some((m) => m.userId === user?.id))) && - !tournament.isLeagueDivision && - !matchIsLocked && - data.match.startedAt && - !data.matchIsOver ? ( - - ) : null} - {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 { t } = useTranslation(["tournament"]); - const user = useUser(); - const tournament = useTournament(); - const data = useLoaderData(); - const isCustomFlow = data.match.roundMaps?.pickBan === "CUSTOM"; - const validTabs = isCustomFlow - ? ["rosters", "actions", "map-info"] - : ["rosters", "actions"]; - const [selectedTabKey, setSelectedTabKey] = useSearchParamState({ - defaultValue: "rosters", - name: "tab", - revive: (value) => (validTabs.includes(value) ? value : null), - }); - - 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))} - className={styles.matchTabs} - > - - Rosters - - {presentational ? "Score" : "Actions"} - - {isCustomFlow ? ( - - {t("tournament:match.tab.mapInfo")} - - ) : null} - - - - - - - - - - - {isCustomFlow ? ( - - - - ) : null} - - - ); -} - -function ActionSectionWrapper({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function ScreenBanIcons({ banned }: { banned: boolean }) { - const { t } = useTranslation(["weapons"]); - - return ( -
- {banned ? : } - {t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)} -
- ); -} - -function EndSetPopover({ - teams, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - const { t } = useTranslation(["tournament"]); - const [selectedWinner, setSelectedWinner] = React.useState< - number | null | undefined - >(undefined); - - return ( - - {t("tournament:match.action.endSet")} - - } - > -
-
- - - - - - - -
- - - - - {t("tournament:match.action.confirmEndSet")} - -
-
- ); -} diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx deleted file mode 100644 index f0a7097fd..000000000 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ /dev/null @@ -1,527 +0,0 @@ -import clsx from "clsx"; -import * as React from "react"; -import { Link, useFetcher, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; -import { SendouButton } from "~/components/elements/Button"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { tournamentTeamPage, userPage } from "~/utils/urls"; -import { useTournament } from "../../tournament/routes/to.$id"; -import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import styles from "../tournament-bracket.module.css"; -import { tournamentTeamToActiveRosterUserIds } from "../tournament-bracket-utils"; -import type { Result } from "./StartedMatch"; - -/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */ -export function TeamRosterInputs({ - teams, - winnerId, - setWinnerId, - checkedPlayers, - setCheckedPlayers, - points: _points, - setPoints, - result, - revising, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; - winnerId?: number | null; - checkedPlayers: [number[], number[]]; - setCheckedPlayers?: React.Dispatch< - React.SetStateAction<[number[], number[]]> - >; - points?: [number, number]; - setWinnerId: (newId?: number) => void; - setPoints: React.Dispatch>; - result?: Result; - revising?: boolean; -}) { - const tournament = useTournament(); - - const presentational = !revising && Boolean(result); - - const points = - typeof result?.opponentOnePoints === "number" && - typeof result?.opponentTwoPoints === "number" && - !revising - ? ([result.opponentOnePoints, result.opponentTwoPoints] as [ - number, - number, - ]) - : _points; - - return ( -
- {teams.map((team, teamI) => { - const winnerRadioChecked = result - ? result.winnerTeamId === team.id - : winnerId === team.id; - - return ( - - tournamentTeamToActiveRosterUserIds( - team, - tournament.minMembersPerTeam, - ), - )} - setWinnerId={setWinnerId} - setCheckedPlayers={setCheckedPlayers} - checkedPlayers={checkedPlayers[teamI].join(",")} - winnerRadioChecked={winnerRadioChecked} - points={points ? points[teamI] : undefined} - result={result} - revising={revising} - /> - ); - })} -
- ); -} - -function TeamRoster({ - team, - bothTeamsHaveActiveRosters, - presentational, - idx, - setWinnerId, - setPoints, - setCheckedPlayers, - points, - winnerRadioChecked, - checkedPlayers, - result, - revising, -}: { - team: TournamentDataTeam; - bothTeamsHaveActiveRosters: boolean; - presentational: boolean; - idx: number; - setWinnerId: (newId?: number) => void; - setPoints: React.Dispatch>; - setCheckedPlayers?: React.Dispatch< - React.SetStateAction<[number[], number[]]> - >; - points?: number; - winnerRadioChecked: boolean; - checkedPlayers: string; - result?: Result; - revising?: boolean; -}) { - const tournament = useTournament(); - const activeRoster = tournamentTeamToActiveRosterUserIds( - team, - tournament.minMembersPerTeam, - ); - - const user = useUser(); - - const canEditRoster = - (team.members.some((member) => member.userId === user?.id) || - tournament.isOrganizer(user)) && - !presentational && - team.members.length > tournament.minMembersPerTeam; - const [_editingRoster, _setEditingRoster] = React.useState( - !activeRoster && canEditRoster, - ); - - const editingRoster = revising || _editingRoster; - - const setEditingRoster = (editing: boolean) => { - const didCancel = !editing; - if (didCancel) { - setCheckedPlayers?.((oldPlayers) => { - const newPlayers = structuredClone(oldPlayers); - newPlayers[idx] = activeRoster ?? []; - return newPlayers; - }); - } - - _setEditingRoster(editing); - }; - - const hasPoints = typeof points === "number"; - - // just so we can center the points nicely - const showWinnerRadio = !hasPoints || !presentational || winnerRadioChecked; - - const onPointsChange = React.useCallback( - (newPoint: number) => { - setPoints((points) => { - const newPoints = structuredClone(points); - newPoints[idx] = newPoint; - return newPoints; - }); - }, - [idx, setPoints], - ); - - const checkedInputPlayerIds = () => { - if (result?.participants && !revising) { - return result.participants - .filter( - (participant) => - !participant.tournamentTeamId || - participant.tournamentTeamId === team.id, - ) - .map((participant) => participant.userId); - } - if (editingRoster) return checkedPlayers.split(",").map(Number); - - return activeRoster ?? []; - }; - - const checkedPlayersArray = checkedPlayers.split(",").map(Number); - - return ( -
- -
- {showWinnerRadio ? ( - setWinnerId?.(team.id)} - team={idx + 1} - invisible={!bothTeamsHaveActiveRosters} - /> - ) : null} - {hasPoints ? ( - - ) : null} -
- { - if (!setCheckedPlayers) return; - - setCheckedPlayers((oldPlayers) => { - const newPlayers = structuredClone(oldPlayers); - if (oldPlayers[idx].includes(playerId)) { - newPlayers[idx] = newPlayers[idx].filter((id) => id !== playerId); - } else { - newPlayers[idx].push(playerId); - } - - return newPlayers; - }); - }} - /> - {!revising && canEditRoster ? ( - - ) : null} -
- ); -} - -function TeamRosterHeader({ - idx, - team, - tournamentId, -}: { - idx: number; - team: TournamentDataTeam; - tournamentId: number; -}) { - return ( - <> -
-
- Team {idx + 1} -
-

- {team.seed ? ( - #{team.seed} - ) : null}{" "} - - {team.name} - -

- - ); -} - -/** Renders radio button to select the winner, or in presentational mode just displays the text "Winner" */ -function WinnerRadio({ - presentational, - teamId, - checked, - onChange, - team, - invisible, -}: { - presentational: boolean; - teamId: number; - checked: boolean; - onChange: () => void; - team: number; - invisible: boolean; -}) { - const id = React.useId(); - - if (presentational) { - return ( -
- Winner -
- ); - } - - return ( -
- - -
- ); -} - -function PointInput({ - value, - onChange, - presentational, - disabled, - testId, -}: { - value: number; - onChange: (newPoint: number) => void; - presentational: boolean; - disabled: boolean; - testId?: string; -}) { - const [focused, setFocused] = React.useState(false); - const id = React.useId(); - - if (presentational) { - return ( -
- {value === 100 ? "KO" : <>{value}p} -
- ); - } - - return ( -
- onChange(Number(e.target.value))} - type="number" - min={0} - max={100} - disabled={disabled} - value={focused && !value ? "" : String(value)} - required - id={id} - data-testid={testId} - pattern="[0-9]*" - inputMode="numeric" - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - /> - -
- ); -} - -function TeamRosterInputsCheckboxes({ - teamId, - checkedPlayers, - handlePlayerClick, - presentational, -}: { - teamId: number; - checkedPlayers: number[]; - handlePlayerClick: (playerId: number) => void; - presentational: boolean; -}) { - const data = useLoaderData(); - const id = React.useId(); - const tournament = useTournament(); - - const members = data.match.players.filter( - (p) => p.tournamentTeamId === teamId, - ); - - const mode = () => { - if (presentational) return "PRESENTATIONAL"; - - // Disabled in this case because we expect a result to have exactly - // TOURNAMENT_TEAM_ROSTER_MIN_SIZE members per team when reporting it - // so there is no point to let user to change them around - if (members.length <= tournament.minMembersPerTeam) { - return "DISABLED"; - } - - return "DEFAULT"; - }; - - return ( -
- {members.map((member, i) => { - return ( -
-
- handlePlayerClick(member.id)} - data-testid={`player-checkbox-${i}`} - />{" "} - -
- - - -
- ); - })} -
- ); -} - -function RosterFormWithButtons({ - idx, - editingRoster, - setEditingRoster, - showCancelButton, - checkedPlayers, - teamId, - valid, -}: { - idx: number; - editingRoster: boolean; - setEditingRoster: (editing: boolean) => void; - showCancelButton?: boolean; - checkedPlayers: number[]; - teamId: number; - valid: boolean; -}) { - const fetcher = useFetcher(); - - if (!editingRoster) { - return ( -
- setEditingRoster(true)} - className={styles.editRosterButton} - variant="minimal" - data-testid="edit-active-roster-button" - > - Edit active roster - -
- ); - } - - return ( - - - - - Save - - {showCancelButton ? ( - { - setEditingRoster(false); - }} - > - Cancel - - ) : null} - - ); -} diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.tsx b/app/features/tournament-bracket/components/TournamentTeamActions.tsx index 06de62271..96c41beda 100644 --- a/app/features/tournament-bracket/components/TournamentTeamActions.tsx +++ b/app/features/tournament-bracket/components/TournamentTeamActions.tsx @@ -9,6 +9,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { soundEnabled, soundVolume } from "~/features/chat/chat-utils"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; import { logger } from "~/utils/logger"; import { soundPath, @@ -21,6 +22,7 @@ export function TournamentTeamActions() { const tournament = useTournament(); const user = useUser(); const fetcher = useFetcher(); + const { formatTime } = useTimeFormat(); const status = tournament.teamMemberOfProgressStatus(user); @@ -104,13 +106,13 @@ export function TournamentTeamActions() { ) : bracket.startTime && bracket.startTime > new Date() ? ( open{" "} - {sub(bracket.startTime, { hours: 1 }).toLocaleTimeString("en-US", { + {formatTime(sub(bracket.startTime, { hours: 1 }), { hour: "numeric", minute: "numeric", weekday: "short", })}{" "} -{" "} - {bracket.startTime.toLocaleTimeString("en-US", { + {formatTime(bracket.startTime, { hour: "numeric", minute: "numeric", })} diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 56634639a..120a9ff7b 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -45,6 +45,7 @@ export interface Standing { mapWins: number; mapLosses: number; points: number; + koCount?: number; winsAgainstTied: number; lossesAgainstTied?: number; opponentSetWinPercentage?: number; diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index 332517d0f..41535ce5d 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -103,6 +103,7 @@ export class RoundRobinBracket extends Bracket { mapLosses: number; winsAgainstTied: number; points: number; + koCount: number; }[] = []; const updateTeam = ({ @@ -112,6 +113,7 @@ export class RoundRobinBracket extends Bracket { mapWins, mapLosses, points, + koCount, }: { teamId: number; setWins: number; @@ -119,6 +121,7 @@ export class RoundRobinBracket extends Bracket { mapWins: number; mapLosses: number; points: number; + koCount: number; }) => { const team = teams.find((team) => team.id === teamId); if (team) { @@ -127,6 +130,7 @@ export class RoundRobinBracket extends Bracket { team.mapWins += mapWins; team.mapLosses += mapLosses; team.points += points; + team.koCount += koCount; } else { teams.push({ id: teamId, @@ -136,6 +140,7 @@ export class RoundRobinBracket extends Bracket { mapLosses, winsAgainstTied: 0, points, + koCount, }); } }; @@ -180,6 +185,7 @@ export class RoundRobinBracket extends Bracket { mapWins: winner.score ?? 0, mapLosses: loser.score ?? 0, points: winner.totalPoints ?? 0, + koCount: winner.totalKos ?? 0, }); updateTeam({ teamId: loser.id, @@ -188,6 +194,7 @@ export class RoundRobinBracket extends Bracket { mapWins: loser.score ?? 0, mapLosses: winner.score ?? 0, points: loser.totalPoints ?? 0, + koCount: loser.totalKos ?? 0, }); } @@ -262,6 +269,7 @@ export class RoundRobinBracket extends Bracket { mapWins: team.mapWins, mapLosses: team.mapLosses, points: team.points, + koCount: team.koCount, winsAgainstTied: team.winsAgainstTied, }, }; diff --git a/app/features/tournament-bracket/core/PickBan.test.ts b/app/features/tournament-bracket/core/PickBan.test.ts index da52f851d..a096cbe6d 100644 --- a/app/features/tournament-bracket/core/PickBan.test.ts +++ b/app/features/tournament-bracket/core/PickBan.test.ts @@ -3,12 +3,14 @@ import type { TournamentRoundMaps } from "~/db/tables"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { CUSTOM_FLOW_VALIDATION_ERRORS, + currentTurnSessionStartedAt, isModeLegal, mapsListWithLegality, type PickBanEvent, type PickBanTeam, resolveCurrentStep, resolveTeamFromSide, + teamOfEvent, turnOf, validateCustomFlowSection, } from "./PickBan"; @@ -827,6 +829,66 @@ describe("mapsListWithLegality — MODE_PICK restriction survives intervening ev }); }); +describe("mapsListWithLegality — pre-set MODE_BAN persists into postGame", () => { + const SZ = "SZ" as ModeShort; + const TC = "TC" as ModeShort; + const RM = "RM" as ModeShort; + + const toSetMapPool = [ + { mode: SZ, stageId: 1 as StageId }, + { mode: SZ, stageId: 2 as StageId }, + { mode: TC, stageId: 3 as StageId }, + { mode: TC, stageId: 4 as StageId }, + { mode: RM, stageId: 5 as StageId }, + ]; + + const teams = [{ mapPool: [] }, { mapPool: [] }] as unknown as Parameters< + typeof mapsListWithLegality + >[0]["teams"]; + + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [{ action: "MODE_BAN", side: "HIGHER_SEED" }, { action: "ROLL" }], + postGame: [ + { action: "BAN", side: "WINNER" }, + { action: "PICK", side: "LOSER" }, + ], + }, + }; + + it("keeps a mode banned in pre-set unavailable for picks in later postGame cycles", () => { + // preSet: HIGHER_SEED bans mode SZ, ROLL lands on TC stage 3 + // game 1: TC stage 3 played, team 200 wins + // postGame cycle 1: WINNER (200) bans stage 4 (TC); LOSER (100) is now at PICK + const pickBanEvents: PickBanEvent[] = [ + { type: "MODE_BAN", stageId: null, mode: SZ }, + { type: "ROLL", stageId: 3 as StageId, mode: TC }, + { type: "BAN", stageId: 4 as StageId, mode: TC }, + ]; + + const result = mapsListWithLegality({ + results: [{ mode: TC, stageId: 3 as StageId, winnerTeamId: 200 }], + maps: customMaps, + mapList: null, + teams, + pickerTeamId: 100, + tieBreakerMapPool: [], + toSetMapPool, + pickBanEvents, + }); + + const legalModes = new Set( + result.filter((m) => m.isLegal).map((m) => m.mode), + ); + + expect(legalModes.has(SZ)).toBe(false); + expect(legalModes).toEqual(new Set([RM])); + }); +}); + describe("isModeLegal", () => { const SZ = "SZ" as ModeShort; const TC = "TC" as ModeShort; @@ -932,3 +994,303 @@ describe("turnOf — COUNTERPICK flow", () => { expect(result).toEqual({ teamId: 100, action: "PICK" }); }); }); + +describe("teamOfEvent", () => { + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("returns null when setup is not pick/ban", () => { + const result = teamOfEvent({ + eventIndex: 0, + maps: { count: 3, type: "BEST_OF" }, + teams, + results: [], + }); + + expect(result).toBeNull(); + }); + + describe("BAN_2", () => { + const ban2Maps: TournamentRoundMaps = { + count: 3, + type: "BEST_OF", + pickBan: "BAN_2", + }; + + it("assigns event 0 to teams[1] (first picker)", () => { + expect( + teamOfEvent({ eventIndex: 0, maps: ban2Maps, teams, results: [] }), + ).toBe(200); + }); + + it("assigns event 1 to teams[0] (second picker)", () => { + expect( + teamOfEvent({ eventIndex: 1, maps: ban2Maps, teams, results: [] }), + ).toBe(100); + }); + + it("returns null for further indices", () => { + expect( + teamOfEvent({ eventIndex: 2, maps: ban2Maps, teams, results: [] }), + ).toBeNull(); + }); + }); + + describe("COUNTERPICK", () => { + const cpMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "COUNTERPICK", + }; + + it("attributes the counterpick to the loser of the preceding result", () => { + const result = teamOfEvent({ + eventIndex: 0, + maps: cpMaps, + teams, + results: [{ winnerTeamId: 100 }], + }); + + expect(result).toBe(200); + }); + + it("also works for COUNTERPICK_MODE_REPEAT_OK", () => { + const result = teamOfEvent({ + eventIndex: 1, + maps: { ...cpMaps, pickBan: "COUNTERPICK_MODE_REPEAT_OK" }, + teams, + results: [{ winnerTeamId: 100 }, { winnerTeamId: 200 }], + }); + + expect(result).toBe(100); + }); + + it("returns null when no corresponding result exists", () => { + const result = teamOfEvent({ + eventIndex: 0, + maps: cpMaps, + teams, + results: [], + }); + + expect(result).toBeNull(); + }); + }); + + describe("CUSTOM", () => { + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + ], + postGame: [ + { action: "BAN", side: "WINNER" }, + { action: "PICK", side: "LOSER" }, + ], + }, + }; + + it("resolves preSet steps via side (HIGHER_SEED → teams[1])", () => { + expect( + teamOfEvent({ eventIndex: 0, maps: customMaps, teams, results: [] }), + ).toBe(200); + }); + + it("resolves preSet steps via side (LOWER_SEED → teams[0])", () => { + expect( + teamOfEvent({ eventIndex: 1, maps: customMaps, teams, results: [] }), + ).toBe(100); + }); + + it("resolves postGame WINNER using the result of that cycle", () => { + const result = teamOfEvent({ + eventIndex: 2, + maps: customMaps, + teams, + results: [{ winnerTeamId: 100 }], + }); + + expect(result).toBe(100); + }); + + it("resolves postGame LOSER using the result of that cycle", () => { + const result = teamOfEvent({ + eventIndex: 3, + maps: customMaps, + teams, + results: [{ winnerTeamId: 100 }], + }); + + expect(result).toBe(200); + }); + + it("uses the correct cycle's result across multiple post-game cycles", () => { + const result = teamOfEvent({ + eventIndex: 4, + maps: customMaps, + teams, + results: [{ winnerTeamId: 100 }, { winnerTeamId: 200 }], + }); + + expect(result).toBe(200); + }); + + it("returns null when customFlow is missing", () => { + const result = teamOfEvent({ + eventIndex: 0, + maps: { count: 3, type: "BEST_OF", pickBan: "CUSTOM" }, + teams, + results: [], + }); + + expect(result).toBeNull(); + }); + + it("returns null for ROLL steps (no side)", () => { + const rollMaps: TournamentRoundMaps = { + count: 3, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [{ action: "ROLL" }], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + expect( + teamOfEvent({ eventIndex: 0, maps: rollMaps, teams, results: [] }), + ).toBeNull(); + }); + }); +}); + +describe("currentTurnSessionStartedAt", () => { + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("returns null when there is no current turn", () => { + const result = currentTurnSessionStartedAt({ + currentTurn: null, + events: [], + results: [], + matchStartedAt: 1000, + maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" }, + teams, + }); + + expect(result).toBeNull(); + }); + + it("returns null when matchStartedAt is null", () => { + const result = currentTurnSessionStartedAt({ + currentTurn: { teamId: 200, action: "BAN" }, + events: [], + results: [], + matchStartedAt: null, + maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" }, + teams, + }); + + expect(result).toBeNull(); + }); + + it("falls back to matchStartedAt when no events or results exist", () => { + const result = currentTurnSessionStartedAt({ + currentTurn: { teamId: 200, action: "BAN" }, + events: [], + results: [], + matchStartedAt: 1000, + maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" }, + teams, + }); + + expect(result).toBe(1000); + }); + + it("BAN_2: second banner's session starts at the first ban's timestamp", () => { + const result = currentTurnSessionStartedAt({ + currentTurn: { teamId: 100, action: "BAN" }, + events: [{ createdAt: 1500 }], + results: [], + matchStartedAt: 1000, + maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" }, + teams, + }); + + expect(result).toBe(1500); + }); + + it("COUNTERPICK: loser's session starts when the result is reported", () => { + const result = currentTurnSessionStartedAt({ + currentTurn: { teamId: 200, action: "PICK" }, + events: [], + results: [{ createdAt: 2000, winnerTeamId: 100 }], + matchStartedAt: 1000, + maps: { count: 5, type: "BEST_OF", pickBan: "COUNTERPICK" }, + teams, + }); + + expect(result).toBe(2000); + }); + + it("CUSTOM: consecutive same-team events share the session start", () => { + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + ], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + const result = currentTurnSessionStartedAt({ + currentTurn: { teamId: 200, action: "BAN" }, + events: [{ createdAt: 1500 }], + results: [], + matchStartedAt: 1000, + maps: customMaps, + teams, + }); + + expect(result).toBe(1000); + }); + + it("CUSTOM: a result restarts the session even when the same team is responsible again", () => { + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [{ action: "BAN", side: "HIGHER_SEED" }], + postGame: [ + { action: "PICK", side: "LOSER" }, + { action: "BAN", side: "LOSER" }, + ], + }, + }; + + const result = currentTurnSessionStartedAt({ + currentTurn: { teamId: 100, action: "BAN" }, + events: [{ createdAt: 1100 }, { createdAt: 2500 }], + results: [{ createdAt: 2000, winnerTeamId: 200 }], + matchStartedAt: 1000, + maps: customMaps, + teams, + }); + + expect(result).toBe(2000); + }); +}); diff --git a/app/features/tournament-bracket/core/PickBan.ts b/app/features/tournament-bracket/core/PickBan.ts index de766472a..4aea47eb5 100644 --- a/app/features/tournament-bracket/core/PickBan.ts +++ b/app/features/tournament-bracket/core/PickBan.ts @@ -5,6 +5,7 @@ import type { TournamentRoundMaps, WhoSide, } from "~/db/tables"; +import { isSetOverByResults } from "~/features/tournament-match/tournament-match-utils"; import type { ModeShort, ModeWithStage, @@ -14,7 +15,6 @@ import type { TournamentMapListMap } from "~/modules/tournament-map-list-generat import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; -import { isSetOverByResults } from "../tournament-bracket-utils"; import type { TournamentDataTeam } from "./Tournament.server"; export const types = [ @@ -240,11 +240,12 @@ export function resolveCurrentStep({ "resolveCurrentStep: postGame must not be empty", ); - const eventsAfterPreSet = eventCount - preSet.length; - const stepInPostGame = eventsAfterPreSet % postGame.length; - const completedPostGameCycles = Math.floor( - eventsAfterPreSet / postGame.length, - ); + const stepInPostGame = (eventCount - preSet.length) % postGame.length; + const completedPostGameCycles = postGameCycleIndex({ + eventIndex: eventCount, + preSetLength: preSet.length, + postGameLength: postGame.length, + }); // waiting for game result if (completedPostGameCycles > resultsCount) return null; @@ -255,6 +256,26 @@ export function resolveCurrentStep({ return postGame[stepInPostGame]!; } +/** + * Returns the 0-based post-game cycle index for an event position. For an + * event at `eventIndex`, this is the cycle the event belongs to; for a count + * of events done so far, this is how many post-game cycles have been at least + * started past the pre-set. + * + * Caller must ensure `eventIndex >= preSetLength` and `postGameLength > 0`. + */ +export function postGameCycleIndex({ + eventIndex, + preSetLength, + postGameLength, +}: { + eventIndex: number; + preSetLength: number; + postGameLength: number; +}): number { + return Math.floor((eventIndex - preSetLength) / postGameLength); +} + export function resolveTeamFromSide({ side, teams, @@ -288,6 +309,134 @@ export function resolveTeamFromSide({ } } +/** + * Resolves which team is responsible for the pick/ban event at a given index, + * across all pick/ban variants (BAN_2, COUNTERPICK, COUNTERPICK_MODE_REPEAT_OK, + * CUSTOM). Returns null when the setup is not pick/ban or the team cannot be + * determined (e.g. a CUSTOM ROLL step, or insufficient results). + */ +export function teamOfEvent({ + eventIndex, + maps, + teams, + results, +}: { + eventIndex: number; + maps: TournamentRoundMaps; + teams: [PickBanTeam, PickBanTeam]; + results: Array<{ winnerTeamId: number }>; +}): number | null { + if (!maps.pickBan) return null; + + switch (maps.pickBan) { + case "BAN_2": { + // turnOf uses: [secondPicker, firstPicker] = teams, so teams[1] bans + // first (event 0), teams[0] second (event 1). + if (eventIndex === 0) return teams[1].id; + if (eventIndex === 1) return teams[0].id; + return null; + } + case "COUNTERPICK": + case "COUNTERPICK_MODE_REPEAT_OK": { + // Each counterpick follows a played map; the loser of that map picks. + const result = results[eventIndex]; + if (!result) return null; + return teams.find((t) => t.id !== result.winnerTeamId)?.id ?? null; + } + case "CUSTOM": { + const customFlow = maps.customFlow; + if (!customFlow) return null; + + const { preSet, postGame } = customFlow; + const step = + eventIndex < preSet.length + ? preSet[eventIndex] + : postGame.length > 0 + ? postGame[(eventIndex - preSet.length) % postGame.length] + : null; + if (!step?.side) return null; + + // WINNER/LOSER sides are relative to the latest result at the time + // of the event, so slice results to the correct post-game cycle. + if (step.side === "WINNER" || step.side === "LOSER") { + const cycleIndex = postGameCycleIndex({ + eventIndex, + preSetLength: preSet.length, + postGameLength: postGame.length, + }); + if (!results[cycleIndex]) return null; + return resolveTeamFromSide({ + side: step.side, + teams, + results: results.slice(0, cycleIndex + 1), + }); + } + + return resolveTeamFromSide({ side: step.side, teams, results }); + } + default: { + assertUnreachable(maps.pickBan); + } + } +} + +type TimelineItem = + | { kind: "event"; index: number; createdAt: number } + | { kind: "result"; createdAt: number }; + +/** + * Resolves the timestamp at which the currently responsible team's pick/ban + * session started. The session begins when responsibility transitions to that + * team and continues across multiple consecutive actions by the same team. + * A game result always ends the prior session, so a team that becomes + * responsible after a result starts a fresh session at the result's timestamp. + * + * Returns `null` when there is no current pick/ban turn or `matchStartedAt` is + * not available. When no transitions exist yet, returns `matchStartedAt`. + */ +export function currentTurnSessionStartedAt({ + currentTurn, + events, + results, + matchStartedAt, + maps, + teams, +}: { + currentTurn: TurnOfResult | null; + events: Array<{ createdAt: number }>; + results: Array<{ createdAt: number; winnerTeamId: number }>; + matchStartedAt: number | null; + maps: TournamentRoundMaps; + teams: [PickBanTeam, PickBanTeam]; +}): number | null { + if (!currentTurn || matchStartedAt == null) return null; + + const timeline: TimelineItem[] = []; + for (let i = 0; i < events.length; i++) { + timeline.push({ kind: "event", index: i, createdAt: events[i]!.createdAt }); + } + for (const result of results) { + timeline.push({ kind: "result", createdAt: result.createdAt }); + } + timeline.sort((a, b) => a.createdAt - b.createdAt); + + for (let i = timeline.length - 1; i >= 0; i--) { + const item = timeline[i]!; + if (item.kind === "event") { + const teamMadeIt = teamOfEvent({ + eventIndex: item.index, + maps, + teams, + results, + }); + if (teamMadeIt === currentTurn.teamId) continue; + } + return item.createdAt; + } + + return matchStartedAt; +} + export function isLegal({ map, ...rest @@ -308,6 +457,15 @@ export function isModeLegal({ return pool.some((m) => m.mode === mode && m.isLegal); } +export function isStageLegal({ + stageId, + ...rest +}: MapListWithStatusesArgs & { stageId: StageId }) { + const pool = mapsListWithLegality(rest); + + return pool.some((m) => m.stageId === stageId && m.isLegal); +} + export interface PickBanEvent { type: string; stageId: StageId | null; @@ -465,8 +623,9 @@ function unavailableModes({ } if (maps.pickBan === "CUSTOM") { + const events = pickBanEvents ?? []; const currentSectionEvents = currentSectionPickBanEvents({ - pickBanEvents: pickBanEvents ?? [], + pickBanEvents: events, maps, }); @@ -477,11 +636,16 @@ function unavailableModes({ return new Set(modesIncluded.filter((m) => m !== modePick.mode)); } - const modeBans = currentSectionEvents + const preSetLength = maps.customFlow?.preSet.length ?? 0; + const preSetModeBans = events + .slice(0, preSetLength) + .filter((e) => e.type === "MODE_BAN" && e.mode !== null) + .map((e) => e.mode!); + const currentSectionModeBans = currentSectionEvents .filter((e) => e.type === "MODE_BAN" && e.mode !== null) .map((e) => e.mode!); - return new Set(modeBans); + return new Set([...preSetModeBans, ...currentSectionModeBans]); } // COUNTERPICK: can't pick the same mode last won on @@ -511,10 +675,14 @@ function currentSectionPickBanEvents({ if (postGameLength === 0) return []; - const eventsAfterPreSet = pickBanEvents.length - preSetLength; const currentCycleStart = preSetLength + - Math.floor(eventsAfterPreSet / postGameLength) * postGameLength; + postGameCycleIndex({ + eventIndex: pickBanEvents.length, + preSetLength, + postGameLength, + }) * + postGameLength; return pickBanEvents.slice(currentCycleStart); } diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 85b5638bb..ffe28d111 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -832,6 +832,46 @@ describe("validatedSources - other rules", () => { expect(Array.isArray(result)).toBe(true); }); + + it("handles EMPTY_PLACEMENTS_ON_NON_SWISS (DE source with empty placements)", () => { + const error = Progression.bracketsToValidationError([ + { + name: "Bracket 1", + type: "double_elimination", + settings: {}, + requiresCheckIn: false, + }, + { + name: "Bracket 2", + type: "double_elimination", + settings: {}, + requiresCheckIn: false, + sources: [{ bracketIdx: 0, placements: [] }], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("EMPTY_PLACEMENTS_ON_NON_SWISS"); + }); + + it("allows empty placements when source is Swiss with advanceThreshold", () => { + const result = Progression.bracketsToValidationError([ + { + name: "Swiss", + type: "swiss", + settings: { advanceThreshold: 3 }, + requiresCheckIn: false, + }, + { + name: "Finals", + type: "double_elimination", + settings: {}, + requiresCheckIn: false, + sources: [{ bracketIdx: 0, placements: [] }], + }, + ]); + + expect(result).toBeNull(); + }); }); describe("isFinals", () => { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 746d21ce1..3efbff1e0 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -112,6 +112,11 @@ export type ValidationError = | { type: "AB_DIVISIONS_ODD_TEAMS_PER_GROUP"; bracketIdx: number; + } + // empty placements is only valid when sourcing from a Swiss bracket with early advance + | { + type: "EMPTY_PLACEMENTS_ON_NON_SWISS"; + bracketIdx: number; }; /** Takes validated brackets and returns them in the format that is ready for user input. */ @@ -289,6 +294,14 @@ export function bracketsToValidationError( }; } + faultyBracketIdx = emptyPlacementsOnNonSwiss(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "EMPTY_PLACEMENTS_ON_NON_SWISS", + bracketIdx: faultyBracketIdx, + }; + } + faultyBracketIdx = abDivisionsOnNonRoundRobin(brackets); if (typeof faultyBracketIdx === "number") { return { @@ -646,6 +659,25 @@ function swissEarlyAdvanceWithoutDestination(brackets: ParsedBracket[]) { return null; } +function emptyPlacementsOnNonSwiss(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + for (const source of bracket.sources ?? []) { + if (source.placements.length > 0) continue; + + const sourceBracket = brackets[source.bracketIdx]; + const isSwissEarlyAdvance = + sourceBracket?.type === "swiss" && + sourceBracket.settings.advanceThreshold; + + if (!isSwissEarlyAdvance) { + return bracketIdx; + } + } + } + + return null; +} + /** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a successful validation */ export function isBrackets( input: ParsedBracket[] | ValidationError, diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 6b81a5937..8bd834934 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1024,7 +1024,7 @@ export class Tournament { }; return { - bracketName, + bracketName: bracketName ?? "Main bracket", roundName, roundNameWithoutMatchIdentifier: roundNameWithoutMatchIdentifier(roundName), diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts index 179e6dcc9..efd74bc71 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -1,7 +1,7 @@ // this file offers database functions specifically for the crud.server.ts file import { sql } from "~/db/sql"; -import type { Tables, TournamentRoundMaps } from "~/db/tables"; +import type { Tables } from "~/db/tables"; import type { Group as GroupType, Match as MatchType, @@ -243,20 +243,17 @@ export class Round { stageId: Tables["TournamentRound"]["stageId"]; groupId: Tables["TournamentRound"]["groupId"]; number: Tables["TournamentRound"]["number"]; - maps: Pick; constructor( id: Tables["TournamentRound"]["id"] | undefined, stageId: Tables["TournamentRound"]["stageId"], groupId: Tables["TournamentRound"]["groupId"], number: Tables["TournamentRound"]["number"], - maps: Pick, ) { this.id = id; this.stageId = stageId; this.groupId = groupId; this.number = number; - this.maps = maps; } insert() { @@ -335,7 +332,9 @@ const match_getByStageIdStm = sql.prepare(/*sql*/ ` select "TournamentMatch".*, sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal", - sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal" + sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal", + sum(case when "TournamentMatchGameResult"."opponentOnePoints" = 100 and "TournamentMatchGameResult"."opponentTwoPoints" = 0 then 1 else 0 end) as "opponentOneKosTotal", + sum(case when "TournamentMatchGameResult"."opponentTwoPoints" = 100 and "TournamentMatchGameResult"."opponentOnePoints" = 0 then 1 else 0 end) as "opponentTwoKosTotal" from "TournamentMatch" left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId" where "TournamentMatch"."stageId" = @stageId @@ -389,9 +388,6 @@ export class Match { groupId: Tables["TournamentMatch"]["groupId"], roundId: Tables["TournamentMatch"]["roundId"], number: Tables["TournamentMatch"]["number"], - _unknown1: null, - _unknown2: null, - _unknown3: null, opponentOne: string, opponentTwo: string, ) { @@ -411,6 +407,8 @@ export class Match { opponentTwo: string; opponentOnePointsTotal: number | null; opponentTwoPointsTotal: number | null; + opponentOneKosTotal: number | null; + opponentTwoKosTotal: number | null; startedAt: number | null; }, ): MatchType { @@ -424,6 +422,7 @@ export class Match { : { ...JSON.parse(rawMatch.opponentOne), totalPoints: rawMatch.opponentOnePointsTotal ?? undefined, + totalKos: rawMatch.opponentOneKosTotal ?? undefined, }, opponent2: rawMatch.opponentTwo === "null" @@ -431,6 +430,7 @@ export class Match { : { ...JSON.parse(rawMatch.opponentTwo), totalPoints: rawMatch.opponentTwoPointsTotal ?? undefined, + totalKos: rawMatch.opponentTwoKosTotal ?? undefined, }, round_id: rawMatch.roundId, stage_id: rawMatch.stageId, diff --git a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts index 45a11a960..31dad6068 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts @@ -1,210 +1,195 @@ -// @ts-nocheck TODO - +import type { + CrudInterface, + DataTypes, + OmitId, + Table, +} from "~/modules/brackets-manager/types"; import { Group, Match, Round, Stage } from "./crud-db.server"; -export class SqlDatabase { - insert(table, arg) { +export class SqlDatabase implements CrudInterface { + insert(table: T, value: OmitId): number; + insert(table: T, values: OmitId[]): boolean; + insert( + table: T, + arg: OmitId | OmitId[], + ): number | boolean { switch (table) { - case "participant": - throw new Error("not implemented"); - // return Team.insertMissing(arg); - case "stage": { + const value = arg as OmitId; const stage = new Stage( undefined, - arg.tournament_id, - arg.number, - arg.name, - arg.type, - JSON.stringify(arg.settings), + value.tournament_id, + value.number, + value.name, + value.type, + JSON.stringify(value.settings), ); - return stage.insert() && stage.id; + stage.insert(); + return stage.id!; } case "group": { - const group = new Group(undefined, arg.stage_id, arg.number); - return group.insert() && group.id; + const value = arg as OmitId; + const group = new Group(undefined, value.stage_id, value.number); + group.insert(); + return group.id!; } case "round": { + const value = arg as OmitId; const round = new Round( undefined, - arg.stage_id, - arg.group_id, - arg.number, + value.stage_id, + value.group_id, + value.number, ); - return round.insert() && round.id; + round.insert(); + return round.id!; } case "match": { + const value = arg as OmitId; const match = new Match( undefined, - arg.status, - arg.stage_id, - arg.group_id, - arg.round_id, - arg.number, - null, - null, - null, - JSON.stringify(arg.opponent1), - JSON.stringify(arg.opponent2), + value.status, + value.stage_id, + value.group_id, + value.round_id, + value.number, + JSON.stringify(value.opponent1), + JSON.stringify(value.opponent2), ); - return match.insert() && match.id; + match.insert(); + return match.id!; } } } - select(table, arg) { + select(table: T): Array | null; + select(table: T, id: number): DataTypes[T] | null; + select( + table: T, + filter: Partial, + ): Array | null; + select( + table: T, + arg?: number | Partial, + ): DataTypes[T] | Array | null { switch (table) { - case "participant": + case "stage": { if (typeof arg === "number") { - throw new Error("not implemented"); - // const team = Team.getById(arg); - // return team && convertTeam(team); + return Stage.getById(arg) as DataTypes[T]; } - if (arg.tournament_id) { - return Team.getByTournamentId(arg.tournament_id); + const filter = arg as Partial | undefined; + if (filter?.tournament_id) { + return Stage.getByTournamentId(filter.tournament_id) as Array< + DataTypes[T] + >; } break; + } - case "stage": + case "group": { if (typeof arg === "number") { - return Stage.getById(arg); + return Group.getById(arg) as DataTypes[T]; } - if (arg.tournament_id && arg.number) { - throw new Error("not implemented"); - // const stage = Stage.getByTournamentAndNumber( - // arg.tournament_id, - // arg.number, - // ); - // return stage && [convertStage(stage)]; + const filter = arg as Partial | undefined; + if (filter?.stage_id && filter.number) { + const group = Group.getByStageAndNumber( + filter.stage_id, + filter.number, + ); + return group ? ([group] as Array) : null; } - if (arg.tournament_id) { - return Stage.getByTournamentId(arg.tournament_id); + if (filter?.stage_id) { + return Group.getByStageId(filter.stage_id) as Array; } break; + } - case "group": - if (!arg) { - throw new Error("not implemented"); - // const groups = Group.getAll(); - // return groups?.map(convertGroup); - } - + case "round": { if (typeof arg === "number") { - return Group.getById(arg); + return Round.getById(arg) as DataTypes[T]; } - if (arg.stage_id && arg.number) { - const group = Group.getByStageAndNumber(arg.stage_id, arg.number); - return group && [group]; + const filter = arg as Partial | undefined; + if (filter?.group_id && filter.number) { + const round = Round.getByGroupAndNumber( + filter.group_id, + filter.number, + ); + return round ? ([round] as Array) : null; } - if (arg.stage_id) { - return Group.getByStageId(arg.stage_id); + if (filter?.group_id) { + return Round.getByGroupId(filter.group_id) as Array; + } + + if (filter?.stage_id) { + return Round.getByStageId(filter.stage_id) as Array; } break; + } - case "round": - if (!arg) { - throw new Error("not implemented"); - // const rounds = Round.getAll(); - // return rounds?.map(convertRound); - } - + case "match": { if (typeof arg === "number") { - return Round.getById(arg); + return Match.getById(arg) as DataTypes[T]; } - if (arg.group_id && arg.number) { - const round = Round.getByGroupAndNumber(arg.group_id, arg.number); - return round && [round]; + const filter = arg as Partial | undefined; + if (filter?.round_id && filter.number) { + const match = Match.getByRoundAndNumber( + filter.round_id, + filter.number, + ); + return match ? ([match] as Array) : null; } - if (arg.group_id) { - return Round.getByGroupId(arg.group_id); + if (filter?.stage_id) { + return Match.getByStageId(filter.stage_id) as Array; } - if (arg.stage_id) { - return Round.getByStageId(arg.stage_id); + if (filter?.round_id) { + return Match.getByRoundId(filter.round_id) as Array; } break; - - case "match": - if (!arg) { - throw new Error("not implemented"); - // const matches = Match.getAll(); - // return matches?.map(convertMatch); - } - - if (typeof arg === "number") { - return Match.getById(arg); - } - - if (arg.round_id && arg.number) { - const match = Match.getByRoundAndNumber(arg.round_id, arg.number); - return match && [match]; - } - - if (arg.stage_id) { - return Match.getByStageId(arg.stage_id); - } - - if (arg.group_id) { - throw new Error("not implemented"); - // const matches = Match.getByGroupId(arg.group_id); - // return matches?.map(convertMatch); - } - - if (arg.round_id) { - return Match.getByRoundId(arg.round_id); - } - - break; - // throw new Error("not implemented"); - // if (typeof arg === "number") { - // const game = MatchGame.getById(arg); - // return game && convertMatchGame(game); - // } - - // if (arg.parent_id && arg.number) { - // const game = MatchGame.getByParentAndNumber( - // arg.parent_id, - // arg.number, - // ); - // return game && [convertMatchGame(game)]; - // } - - // if (arg.parent_id) { - // const games = MatchGame.getByParentId(arg.parent_id); - // return games?.map(convertMatchGame); - // } - - // break; + } } return null; } - update(table, query, update) { + update(table: T, id: number, value: DataTypes[T]): boolean; + update( + table: T, + filter: Partial, + value: Partial, + ): boolean; + update( + table: T, + query: number | Partial, + value: DataTypes[T] | Partial, + ): boolean { switch (table) { - case "stage": + case "stage": { if (typeof query === "number") { + const update = value as Partial; return Stage.updateSettings(query, JSON.stringify(update.settings)); } break; + } - case "match": + case "match": { if (typeof query === "number") { + const update = value as DataTypes["match"]; const match = new Match( query, update.status, @@ -212,95 +197,22 @@ export class SqlDatabase { update.group_id, update.round_id, update.number, - null, - null, - null, JSON.stringify(update.opponent1), JSON.stringify(update.opponent2), ); - return match.update(); } break; - // throw new Error("not implemented"); - // if (typeof query === "number") { - // const game = new MatchGame( - // query, - // update.stage_id, - // update.parent_id, - // update.status, - // update.number, - // null, - // null, - // null, - // JSON.stringify(update.opponent1), - // JSON.stringify(update.opponent2), - // ); - - // return game.update(); - // } - - // if (query.parent_id) { - // const game = new MatchGame( - // undefined, - // update.stage_id, - // query.parent_id, - // update.status, - // update.number, - // null, - // null, - // null, - // JSON.stringify(update.opponent1), - // JSON.stringify(update.opponent2), - // ); - - // return game.updateByParentId(); - // } - - // break; + } } return false; } - delete(_table, _filter) { + delete(table: T): boolean; + delete(table: T, filter: Partial): boolean; + delete(): boolean { throw new Error("not implemented"); - // switch (table) { - // case "stage": - // return Number.isInteger(filter.id) && Stage.deleteById(filter.id); - - // case "group": - // return ( - // Number.isInteger(filter.stage_id) && - // Group.deleteByStageId(filter.stage_id) - // ); - - // case "round": - // return ( - // Number.isInteger(filter.stage_id) && - // Round.deleteByStageId(filter.stage_id) - // ); - - // case "match": - // return ( - // Number.isInteger(filter.stage_id) && - // Match.deleteByStageId(filter.stage_id) - // ); - // if (Number.isInteger(filter.stage_id)) - // return MatchGame.deleteByStageId(filter.stage_id); - // if ( - // Number.isInteger(filter.parent_id) && - // Number.isInteger(filter.number) - // ) - // return MatchGame.deleteByParentAndNumber( - // filter.parent_id, - // filter.number, - // ); - // return false; - - // default: - // return false; - // } } } diff --git a/app/features/tournament-bracket/core/brackets-manager/manager.server.ts b/app/features/tournament-bracket/core/brackets-manager/manager.server.ts index 2199f2465..ce23cce3d 100644 --- a/app/features/tournament-bracket/core/brackets-manager/manager.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/manager.server.ts @@ -3,8 +3,6 @@ import { SqlDatabase } from "./crud.server"; export function getServerTournamentManager() { const storage = new SqlDatabase(); - // TODO: fix this ts-expect-error comment - // @ts-expect-error interface mismatch const manager = new BracketsManager(storage); return manager; diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index c1a1b952e..6f31d9ff8 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -8,14 +8,12 @@ import { userIdsToIdentifier, } from "~/features/mmr/mmr-utils"; import { getBracketProgressionLabel } from "~/features/tournament/tournament-utils"; +import type { AllMatchResult } from "~/features/tournament-match/queries/allMatchResultsByTournamentId.server"; +import { matchEndedEarly } from "~/features/tournament-match/tournament-match-utils"; import invariant from "~/utils/invariant"; import { roundToNDecimalPlaces } from "~/utils/number"; import type { Tables, WinLossParticipationArray } from "../../../db/tables"; -import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; -import { - ensureOneStandingPerUser, - matchEndedEarly, -} from "../tournament-bracket-utils"; +import { ensureOneStandingPerUser } from "../tournament-bracket-utils"; import type { Standing } from "./Bracket"; import type { ParsedBracket } from "./Progression"; import * as Progression from "./Progression"; diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 5687b80b0..277db3aa9 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -1,9 +1,9 @@ import { ordinal, rating } from "openskill"; import { describe, expect, test } from "vitest"; +import type { AllMatchResult } from "~/features/tournament-match/queries/allMatchResultsByTournamentId.server"; import invariant from "~/utils/invariant"; import type { Tables } from "../../../db/tables"; -import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; -import type { ParsedBracket } from "./Progression"; +import type * as Progression from "./Progression"; import { tournamentSummary } from "./summarizer.server"; import type { TournamentDataTeam } from "./Tournament.server"; @@ -81,7 +81,7 @@ describe("tournamentSummary()", () => { id: number; abDivision: 0 | 1; }>; - progression?: ParsedBracket[]; + progression?: Progression.ParsedBracket[]; finalStandings?: Array<{ placement: number; team: TournamentDataTeam; diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index a9718d5e4..5da540050 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -291,7 +291,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ tier: null, tentativeTier: null, discordUrl: "https://discord.gg/A6NW3VCHRh", - tags: "REGION,SZ", + tags: "REGION", settings: { bracketProgression: [ { diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts index 93ce776dc..3b7601c09 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts @@ -12,7 +12,7 @@ import * as Standings from "~/features/tournament/core/Standings"; import { tournamentSummary } from "~/features/tournament-bracket/core/summarizer.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; -import { allMatchResultsByTournamentId } from "~/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; +import { allMatchResultsByTournamentId } from "~/features/tournament-match/queries/allMatchResultsByTournamentId.server"; import invariant from "~/utils/invariant"; import type { SerializeFrom } from "~/utils/remix"; import { parseParams } from "~/utils/remix.server"; diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx deleted file mode 100644 index 3252ddf9b..000000000 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ /dev/null @@ -1,352 +0,0 @@ -import clsx from "clsx"; -import { ArrowLeft } from "lucide-react"; -import * as React from "react"; -import { Form, useLoaderData, useRevalidator } from "react-router"; -import { LinkButton } from "~/components/elements/Button"; -import { containerClassName } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import { useSearchParamState } from "~/hooks/useSearchParamState"; -import { useVisibilityChange } from "~/hooks/useVisibilityChange"; -import invariant from "~/utils/invariant"; -import { assertUnreachable } from "~/utils/types"; -import { tournamentBracketsPage } from "~/utils/urls"; -import { action } from "../actions/to.$id.matches.$mid.server"; -import { CastInfo } from "../components/CastInfo"; -import { MatchRosters } from "../components/MatchRosters"; -import { OrganizerMatchMapListDialog } from "../components/OrganizerMatchMapListDialog"; -import { StartedMatch } from "../components/StartedMatch"; -import { getRounds } from "../core/rounds"; -import { loader } from "../loaders/to.$id.matches.$mid.server"; -import { groupNumberToLetters } from "../tournament-bracket-utils"; - -export { action, loader }; - -import styles from "../tournament-bracket.module.css"; - -export default function TournamentMatchPage() { - const user = useUser(); - const visibility = useVisibilityChange(); - const { revalidate } = useRevalidator(); - const tournament = useTournament(); - const data = useLoaderData(); - - React.useEffect(() => { - if (visibility !== "visible" || tournament.ctx.isFinalized) return; - - revalidate(); - }, [visibility, revalidate, tournament.ctx.isFinalized]); - - const type = - tournament.canReportScore({ matchId: data.match.id, user }) || - tournament.isOrganizerOrStreamer(user) - ? "EDIT" - : "OTHER"; - - const showRosterPeek = () => { - if (data.matchIsOver) return false; - - if (!data.match.opponentOne?.id || !data.match.opponentTwo?.id) return true; - - return type !== "EDIT"; - }; - - return ( -
-
- -
- {tournament.isOrganizerOrStreamer(user) ? ( - - ) : null} - } - testId="back-to-bracket-button" - > - Back to bracket - -
-
-
- 0) || - (data.match.opponentTwo?.score && - data.match.opponentTwo.score > 0), - )} - matchIsOver={data.matchIsOver} - matchId={data.match.id} - matchStatus={data.match.status} - /> - {data.matchIsOver && !data.endedEarly && data.results.length > 0 ? ( - - ) : null} - {data.matchIsOver && data.endedEarly ? : null} - {!data.matchIsOver && - typeof data.match.opponentOne?.id === "number" && - typeof data.match.opponentTwo?.id === "number" ? ( - - ) : null} - {showRosterPeek() ? ( - - ) : null} -
-
- ); -} - -function MatchHeader() { - const tournament = useTournament(); - const data = useLoaderData(); - - const { bracketName, roundName } = React.useMemo(() => { - let bracketName: string | undefined; - let roundName: string | undefined; - - for (const bracket of tournament.brackets) { - if (bracket.preview) continue; - - for (const match of bracket.data.match) { - if (match.id === data.match.id) { - bracketName = bracket.name; - - if (bracket.type === "round_robin") { - const group = bracket.data.group.find( - (group) => group.id === match.group_id, - ); - const round = bracket.data.round.find( - (round) => round.id === match.round_id, - ); - - roundName = `Groups ${group?.number ? groupNumberToLetters(group.number) : ""}${round?.number ?? ""}.${match.number}`; - } else if (bracket.type === "swiss") { - const group = bracket.data.group.find( - (group) => group.id === match.group_id, - ); - const round = bracket.data.round.find( - (round) => round.id === match.round_id, - ); - - const oneGroupOnly = bracket.data.group.length === 1; - - roundName = `Swiss${oneGroupOnly ? "" : " Group"} ${group?.number && !oneGroupOnly ? groupNumberToLetters(group.number) : ""} ${round?.number ?? ""}.${match.number}`; - } else if ( - bracket.type === "single_elimination" || - bracket.type === "double_elimination" - ) { - const rounds = - bracket.type === "single_elimination" - ? getRounds({ type: "single", bracketData: bracket.data }) - : [ - ...getRounds({ - type: "winners", - bracketData: bracket.data, - }), - ...getRounds({ type: "losers", bracketData: bracket.data }), - ]; - - const round = rounds.find((round) => round.id === match.round_id); - - if (round) { - const specifier = () => { - if ( - [ - TOURNAMENT.ROUND_NAMES.WB_FINALS, - TOURNAMENT.ROUND_NAMES.GRAND_FINALS, - TOURNAMENT.ROUND_NAMES.BRACKET_RESET, - TOURNAMENT.ROUND_NAMES.FINALS, - TOURNAMENT.ROUND_NAMES.LB_FINALS, - TOURNAMENT.ROUND_NAMES.LB_SEMIS, - TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH, - ].includes(round.name as any) - ) { - return ""; - } - - const roundNameEndsInDigit = /\d$/.test(round.name); - - if (!roundNameEndsInDigit) { - return ` ${match.number}`; - } - - return `.${match.number}`; - }; - roundName = `${round.name}${specifier()}`; - } - } else { - assertUnreachable(bracket.type); - } - } - } - } - - return { - bracketName, - roundName, - }; - }, [tournament, data.match.id]); - - return ( -
-

{roundName}

- {tournament.ctx.settings.bracketProgression.length > 1 ? ( -
{bracketName}
- ) : null} -
- ); -} - -function MapListSection({ - teams, - type, -}: { - teams: [id: number, id: number]; - type: "EDIT" | "OTHER"; -}) { - const data = useLoaderData(); - const tournament = useTournament(); - - const teamOneId = teams[0]; - const teamOne = React.useMemo( - () => tournament.teamById(teamOneId), - [teamOneId, tournament], - ); - const teamTwoId = teams[1]; - const teamTwo = React.useMemo( - () => tournament.teamById(teamTwoId), - [teamTwoId, tournament], - ); - - if (!teamOne || !teamTwo) return null; - - invariant(data.mapList, "No mapList found for this map list"); - - const scoreSum = - (data.match.opponentOne?.score ?? 0) + (data.match.opponentTwo?.score ?? 0); - - const currentMap = data.mapList?.filter((m) => !m.bannedByTournamentTeamId)[ - scoreSum - ]; - - return ( - - ); -} - -function ResultsSection() { - const data = useLoaderData(); - const tournament = useTournament(); - const [selectedResultIndex, setSelectedResultIndex] = useSearchParamState({ - defaultValue: data.results.length - 1, - name: "result", - revive: (value) => { - const maybeIndex = Number(value); - if (!Number.isInteger(maybeIndex)) return; - if (maybeIndex < 0 || maybeIndex >= data.results.length) return; - - return maybeIndex; - }, - }); - - const result = data.results[selectedResultIndex]; - invariant(result, "Result is missing"); - - const teamOne = data.match.opponentOne?.id - ? tournament.teamById(data.match.opponentOne.id) - : undefined; - const teamTwo = data.match.opponentTwo?.id - ? tournament.teamById(data.match.opponentTwo.id) - : undefined; - - if (!teamOne || !teamTwo) { - throw new Error("Team is missing"); - } - - const resultSource = data.mapList?.find( - (m) => m.stageId === result.stageId && m.mode === result.mode, - )?.source; - - return ( - - ); -} - -function EndedEarlyMessage() { - const user = useUser(); - const data = useLoaderData(); - const tournament = useTournament(); - - const winnerTeamId = - data.match.opponentOne?.result === "win" - ? data.match.opponentOne.id - : data.match.opponentTwo?.result === "win" - ? data.match.opponentTwo.id - : null; - - const winnerTeam = winnerTeamId ? tournament.teamById(winnerTeamId) : null; - - const opponentOneTeam = data.match.opponentOne?.id - ? tournament.teamById(data.match.opponentOne.id) - : null; - const opponentTwoTeam = data.match.opponentTwo?.id - ? tournament.teamById(data.match.opponentTwo.id) - : null; - const droppedTeam = opponentOneTeam?.droppedOut - ? opponentOneTeam - : opponentTwoTeam?.droppedOut - ? opponentTwoTeam - : null; - - return ( -
-
-
-
Match ended early
- {winnerTeam ? ( -
- {droppedTeam - ? `${droppedTeam.name} dropped out of the tournament.` - : "The organizer ended this match as it exceeded the time limit."}{" "} - Winner: {winnerTeam.name} -
- ) : null} -
- {tournament.isOrganizer(user) && - tournament.matchCanBeReopened(data.match.id) ? ( -
- - Reopen match - -
- ) : null} -
-
- ); -} diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 93b6680d4..fb77cf096 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -9,6 +9,7 @@ import { numericEnum, safeJSONParse, stageId, + weaponSplId, } from "~/utils/zod"; import { TOURNAMENT } from "../tournament/tournament-constants"; import * as PickBan from "./core/PickBan"; @@ -41,15 +42,16 @@ const points = z.preprocess( if (!val) return true; const [p1, p2] = val; - if (p1 === p2) return false; - if (p1 === 100 && p2 !== 0) return false; - if (p2 === 100 && p1 !== 0) return false; + // KO + if (p1 === 100 && p2 === 0) return true; + if (p2 === 100 && p1 === 0) return true; + // ...or no points sent at all (TODO: if we decide that this KO only approach is solid then we can do a proper data model migration) + if (p1 === 0 && p2 === 0) return true; - return true; + return false; }, { - message: - "Invalid points. Must not be equal & if one is 100, the other must be 0.", + message: "Invalid points. Valid: 100-0, 0-100 or 0-0.", }, ), ); @@ -68,7 +70,7 @@ export const matchSchema = z.union([ z.object({ _action: _action("BAN_PICK"), stageId: stageId.optional(), - mode: modeShort, + mode: modeShort.optional(), }), z.object({ _action: _action("UNDO_REPORT_SCORE"), @@ -101,6 +103,15 @@ export const matchSchema = z.union([ _action: _action("END_SET"), winnerTeamId: z.preprocess(nullLiteraltoNull, id.nullable()), }), + z.object({ + _action: _action("REPORT_WEAPON"), + weaponSplId, + mapIndex: z.coerce.number().int().nonnegative(), + }), + z.object({ + _action: _action("UNDO_WEAPON_REPORT"), + mapIndex: z.coerce.number().int().nonnegative(), + }), ]); export const bracketIdx = z.coerce.number().int().min(0).max(100); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.test.ts b/app/features/tournament-bracket/tournament-bracket-utils.test.ts index 0b146b1a4..617ebc08a 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.test.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.test.ts @@ -2,38 +2,9 @@ import { describe, expect, test } from "vitest"; import { fillWithNullTillPowerOfTwo, groupNumberToLetters, - mapCountPlayedInSetWithCertainty, - matchEndedEarly, - resolveRoomPass, validateBadgeReceivers, } from "./tournament-bracket-utils"; -const mapCountParamsToResult: { - bestOf: number; - scores: [number, number]; - expected: number; -}[] = [ - { bestOf: 3, scores: [0, 0], expected: 2 }, - { bestOf: 3, scores: [1, 0], expected: 2 }, - { bestOf: 3, scores: [1, 1], expected: 3 }, - { bestOf: 5, scores: [0, 0], expected: 3 }, - { bestOf: 5, scores: [1, 0], expected: 3 }, - { bestOf: 5, scores: [2, 0], expected: 3 }, - { bestOf: 5, scores: [2, 1], expected: 4 }, - { bestOf: 7, scores: [0, 0], expected: 4 }, - { bestOf: 7, scores: [2, 2], expected: 6 }, -]; - -describe("mapCountPlayedInSetWithCertainty()", () => { - for (const { bestOf, scores, expected } of mapCountParamsToResult) { - test(`bestOf=${bestOf}, scores=${scores.join(",")} -> ${expected}`, () => { - expect(mapCountPlayedInSetWithCertainty({ bestOf, scores })).toBe( - expected, - ); - }); - } -}); - const powerOfTwoParamsToResults: [ amountOfTeams: number, expectedNullCount: number, @@ -74,170 +45,54 @@ describe("groupNumberToLetters()", () => { expect(groupNumberToLetters(groupNumber)).toBe(expected); }); } +}); - describe("validateNewBadgeOwners", () => { - const badges = [{ id: 1 }, { id: 2 }]; +describe("validateNewBadgeOwners", () => { + const badges = [{ id: 1 }, { id: 2 }]; - test("returns BADGE_NOT_ASSIGNED if a badge has no owner", () => { - const badgeReceivers = [ - { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, - ]; - expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe( - "BADGE_NOT_ASSIGNED", - ); - }); - - test("returns BADGE_NOT_ASSIGNED if a badge owner has empty userIds", () => { - const badgeReceivers = [ - { badgeId: 1, userIds: [], tournamentTeamId: 100 }, - { badgeId: 2, userIds: [20], tournamentTeamId: 101 }, - ]; - expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe( - "BADGE_NOT_ASSIGNED", - ); - }); - - test("returns DUPLICATE_TOURNAMENT_TEAM_ID if tournamentTeamId is duplicated", () => { - const badgeReceivers = [ - { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, - { badgeId: 2, userIds: [20], tournamentTeamId: 100 }, - ]; - expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe( - "DUPLICATE_TOURNAMENT_TEAM_ID", - ); - }); - - test("returns BADGE_NOT_FOUND if some receiver has a badge not from the tournament", () => { - const badgeReceivers = [ - { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, - ]; - expect( - validateBadgeReceivers({ badgeReceivers, badges: [{ id: 2 }] }), - ).toBe("BADGE_NOT_FOUND"); - }); - - test("returns null if all badges are assigned and tournamentTeamIds are unique", () => { - const badgeReceivers = [ - { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, - { badgeId: 2, userIds: [20], tournamentTeamId: 101 }, - ]; - expect(validateBadgeReceivers({ badgeReceivers, badges })).toBeNull(); - }); + test("returns BADGE_NOT_ASSIGNED if a badge has no owner", () => { + const badgeReceivers = [ + { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, + ]; + expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe( + "BADGE_NOT_ASSIGNED", + ); }); - describe("resolveRoomPass", () => { - test("returns a 4-digit password", () => { - const pass = resolveRoomPass(12345); - - expect(pass).toMatch(/^\d{4}$/); - }); - - test("returns deterministic password for a given numeric seed", () => { - const pass1 = resolveRoomPass(12345); - const pass2 = resolveRoomPass(12345); - expect(pass1).toBe(pass2); - }); - - test("returns deterministic password for a given string seed", () => { - const pass1 = resolveRoomPass("test-seed"); - const pass2 = resolveRoomPass("test-seed"); - expect(pass1).toBe(pass2); - }); - - test("returns different passwords for different seeds", () => { - const pass1 = resolveRoomPass(1); - const pass2 = resolveRoomPass(2); - expect(pass1).not.toBe(pass2); - }); + test("returns BADGE_NOT_ASSIGNED if a badge owner has empty userIds", () => { + const badgeReceivers = [ + { badgeId: 1, userIds: [], tournamentTeamId: 100 }, + { badgeId: 2, userIds: [20], tournamentTeamId: 101 }, + ]; + expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe( + "BADGE_NOT_ASSIGNED", + ); }); - describe("matchEndedEarly", () => { - test("returns false when no winner", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 1 }, - opponentTwo: { score: 1 }, - count: 3, - countType: "BEST_OF", - }), - ).toBe(false); - }); + test("returns DUPLICATE_TOURNAMENT_TEAM_ID if tournamentTeamId is duplicated", () => { + const badgeReceivers = [ + { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, + { badgeId: 2, userIds: [20], tournamentTeamId: 100 }, + ]; + expect(validateBadgeReceivers({ badgeReceivers, badges })).toBe( + "DUPLICATE_TOURNAMENT_TEAM_ID", + ); + }); - test("returns false when match completed normally (best of 3)", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 2, result: "win" }, - opponentTwo: { score: 1, result: "loss" }, - count: 3, - countType: "BEST_OF", - }), - ).toBe(false); - }); + test("returns BADGE_NOT_FOUND if some receiver has a badge not from the tournament", () => { + const badgeReceivers = [ + { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, + ]; + expect( + validateBadgeReceivers({ badgeReceivers, badges: [{ id: 2 }] }), + ).toBe("BADGE_NOT_FOUND"); + }); - test("returns true when match ended early (best of 3)", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 1, result: "win" }, - opponentTwo: { score: 0, result: "loss" }, - count: 3, - countType: "BEST_OF", - }), - ).toBe(true); - }); - - test("returns true when match ended early (best of 5)", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 2, result: "win" }, - opponentTwo: { score: 1, result: "loss" }, - count: 5, - countType: "BEST_OF", - }), - ).toBe(true); - }); - - test("returns false when match completed normally (best of 5)", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 3, result: "win" }, - opponentTwo: { score: 2, result: "loss" }, - count: 5, - countType: "BEST_OF", - }), - ).toBe(false); - }); - - test("returns false when all maps played (play all)", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 2, result: "win" }, - opponentTwo: { score: 1, result: "loss" }, - count: 3, - countType: "PLAY_ALL", - }), - ).toBe(false); - }); - - test("returns true when not all maps played (play all)", () => { - expect( - matchEndedEarly({ - opponentOne: { score: 2, result: "win" }, - opponentTwo: { score: 0, result: "loss" }, - count: 3, - countType: "PLAY_ALL", - }), - ).toBe(true); - }); - - test("handles missing scores as 0", () => { - expect( - matchEndedEarly({ - opponentOne: { result: "win" }, - opponentTwo: { result: "loss" }, - count: 3, - countType: "BEST_OF", - }), - ).toBe(true); - }); + test("returns null if all badges are assigned and tournamentTeamIds are unique", () => { + const badgeReceivers = [ + { badgeId: 1, userIds: [10], tournamentTeamId: 100 }, + { badgeId: 2, userIds: [20], tournamentTeamId: 101 }, + ]; + expect(validateBadgeReceivers({ badgeReceivers, badges })).toBeNull(); }); }); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index f06c2bf0e..f45683015 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -1,85 +1,10 @@ -import type { TFunction } from "i18next"; -import * as R from "remeda"; -import type { TournamentRoundMaps } from "~/db/tables"; import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; -import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types"; -import { logger } from "~/utils/logger"; -import { seededRandom } from "~/utils/random"; import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server"; import type { Standing } from "./core/Bracket"; -import type { Tournament } from "./core/Tournament"; -import type { TournamentDataTeam } from "./core/Tournament.server"; export const tournamentWebsocketRoom = (tournamentId: number) => `tournament__${tournamentId}`; -export const tournamentMatchWebsocketRoom = (matchId: number) => - `match__${matchId}`; - -const NUM_MAP = { - "1": ["1", "2", "4"], - "2": ["2", "1", "3", "5"], - "3": ["3", "2", "6"], - "4": ["4", "1", "5", "7"], - "5": ["5", "2", "4", "6", "8"], - "6": ["6", "3", "5", "9"], - "7": ["7", "4", "8"], - "8": ["8", "7", "5", "9", "0"], - "9": ["9", "6", "8"], - "0": ["0", "8"], -}; -/** - * Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed. - * - * Given the same seed, this function will always return the same password. - */ -export function resolveRoomPass(seed: number | string) { - let pass = "5"; - for (let i = 0; i < 3; i++) { - const { seededShuffle } = seededRandom(`${seed}-${i}`); - - const key = pass[i] as keyof typeof NUM_MAP; - const opts = NUM_MAP[key]; - const next = seededShuffle(opts)[0]; - pass += next; - } - - // prevent 5555 since many use it as a default pass - // making it a bit more common guess - if (pass === "5555") return "5800"; - - return pass; -} - -export function resolveHostingTeam( - teams: [TournamentDataTeam, TournamentDataTeam], -) { - if (teams[0].prefersNotToHost && !teams[1].prefersNotToHost) return teams[1]; - if (!teams[0].prefersNotToHost && teams[1].prefersNotToHost) return teams[0]; - if (!teams[0].seed && !teams[1].seed) return teams[0]; - if (!teams[0].seed) return teams[1]; - if (!teams[1].seed) return teams[0]; - if (teams[0].seed < teams[1].seed) return teams[0]; - if (teams[1].seed < teams[0].seed) return teams[1]; - - logger.error("resolveHostingTeam: unexpected default"); - return teams[0]; -} - -export function mapCountPlayedInSetWithCertainty({ - bestOf, - scores, -}: { - bestOf: number; - scores: [number, number]; -}) { - const maxScore = Math.max(...scores); - const scoreSum = scores.reduce((acc, curr) => acc + curr, 0); - - return scoreSum + (Math.ceil(bestOf / 2) - maxScore); -} - export function fillWithNullTillPowerOfTwo(arr: T[]) { const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length)); const nullsToAdd = nextPowerOfTwo - arr.length; @@ -87,56 +12,6 @@ export function fillWithNullTillPowerOfTwo(arr: T[]) { return [...arr, ...new Array(nullsToAdd).fill(null)]; } -export function matchIsLocked({ - tournament, - matchId, - scores, -}: { - tournament: Tournament; - matchId: number; - scores: [number, number]; -}) { - if (scores[0] !== 0 || scores[1] !== 0) return false; - - const locked = tournament.ctx.castedMatchesInfo?.lockedMatches ?? []; - - return locked.some((lm) => lm.matchId === matchId); -} - -export function pickInfoText({ - map, - t, - teams, -}: { - map?: { stageId: StageId; mode: ModeShort; source: TournamentMaplistSource }; - t: TFunction<["tournament"]>; - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - if (!map) return ""; - - if (map.source === teams[0].id) { - return t("tournament:pickInfo.team", { number: 1 }); - } - if (map.source === teams[1].id) { - return t("tournament:pickInfo.team", { number: 2 }); - } - if (map.source === "TIEBREAKER") { - return t("tournament:pickInfo.tiebreaker"); - } - if (map.source === "BOTH") return t("tournament:pickInfo.both"); - if (map.source === "DEFAULT") return t("tournament:pickInfo.default"); - if (map.source === "COUNTERPICK") { - return t("tournament:pickInfo.counterpick"); - } - if (map.source === "ROLL") { - return t("tournament:pickInfo.roll"); - } - if (map.source === "TO") return ""; - - logger.error(`Unknown source: ${String(map.source)}`); - return ""; -} - /** * Converts a group number to its corresponding letter representation. * @@ -156,72 +31,6 @@ export function groupNumberToLetters(groupNumber: number) { return letters; } -export function isSetOverByResults({ - results, - count, - countType, -}: { - results: Array<{ winnerTeamId: number }>; - count: number; - countType: TournamentRoundMaps["type"]; -}) { - const winCounts = new Map(); - - for (const result of results) { - const count = winCounts.get(result.winnerTeamId) ?? 0; - winCounts.set(result.winnerTeamId, count + 1); - } - - if (countType === "PLAY_ALL") { - return R.sum(Array.from(winCounts.values())) === count; - } - - const maxWins = Math.max(...Array.from(winCounts.values())); - - // best of - return maxWins >= Math.ceil(count / 2); -} - -export function isSetOverByScore({ - scores, - count, - countType, -}: { - scores: [number, number]; - count: number; - countType: TournamentRoundMaps["type"]; -}) { - if (countType === "PLAY_ALL") { - return R.sum(scores) === count; - } - - const matchOverAtXWins = Math.ceil(count / 2); - return scores[0] === matchOverAtXWins || scores[1] === matchOverAtXWins; -} - -export function matchEndedEarly({ - opponentOne, - opponentTwo, - count, - countType, -}: { - opponentOne: { score?: number; result?: "win" | "loss" }; - opponentTwo: { score?: number; result?: "win" | "loss" }; - count: number; - countType: TournamentRoundMaps["type"]; -}) { - if (opponentOne.result !== "win" && opponentTwo.result !== "win") { - return false; - } - - const scores: [number, number] = [ - opponentOne.score ?? 0, - opponentTwo.score ?? 0, - ]; - - return !isSetOverByScore({ scores, count, countType }); -} - export function tournamentTeamToActiveRosterUserIds( team: TournamentLoaderData["tournament"]["ctx"]["teams"][number], teamMinMemberCount: number, diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index b9b128976..0537a1d9f 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -280,6 +280,23 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } + case "DELETE_GROUP": { + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + errorToastIfFalsy( + tournament.isOrganizer(user), + "Only tournament organizers can remove other groups", + ); + + await TournamentLFGRepository.leaveLfg({ + userId: data.userId, + tournamentId, + }); + + break; + } case "ADD_SUB": { const tournament = await tournamentFromDBCached({ tournamentId, diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index 7f88e36ee..0924e535a 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { Mic, Star, Volume2, VolumeX } from "lucide-react"; +import { Mic, Star, Trash, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; @@ -8,11 +8,13 @@ import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Image, WeaponImage } from "~/components/Image"; import { SubmitButton } from "~/components/SubmitButton"; import type { Pronouns } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; +import { useTournament } from "~/features/tournament/routes/to.$id"; import { SendouForm } from "~/form/SendouForm"; import { useMainContentWidth } from "~/hooks/useMainContentWidth"; import { languagesUnified } from "~/modules/i18n/config"; @@ -58,6 +60,7 @@ export function LFGGroupCard({ const { t } = useTranslation(["common", "q"]); const fetcher = useFetcher(); const user = useUser(); + const tournament = useTournament(); const isOwnGroup = group.id === ownGroup?.id; const showActions = isOwnGroup && group.usersRole === "OWNER"; @@ -66,6 +69,8 @@ export function LFGGroupCard({ ? group.members.find((m) => m.id === user.id) : undefined; + const showOrganizerDelete = !currentMember && tournament.isOrganizer(user); + return (
@@ -118,11 +123,47 @@ export function LFGGroupCard({ ) : null} + {showOrganizerDelete ? ( + + ) : null}
); } +function LFGOrganizerGroupRemover({ group }: { group: LFGGroup }) { + const { t } = useTranslation(["common", "q"]); + + const targetUserId = ( + group.members.find((m) => m.role === "OWNER") ?? group.members[0] + )?.id; + + if (typeof targetUserId !== "number") return null; + + return ( +
+ + } + > + {t("common:actions.delete")} + + +
+ ); +} + function LFGGroupCardContainer({ isOwnGroup, groupId, diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.ts b/app/features/tournament-lfg/tournament-lfg-schemas.ts index f2f1a25e5..edaac4d6c 100644 --- a/app/features/tournament-lfg/tournament-lfg-schemas.ts +++ b/app/features/tournament-lfg/tournament-lfg-schemas.ts @@ -55,6 +55,10 @@ export const lookingSchema = z.union([ z.object({ _action: _action("LEAVE_GROUP"), }), + z.object({ + _action: _action("DELETE_GROUP"), + userId: id, + }), addSubFormSchema, z.object({ _action: _action("DELETE_SUB"), diff --git a/app/features/tournament-match/TournamentMatchRepository.server.test.ts b/app/features/tournament-match/TournamentMatchRepository.server.test.ts new file mode 100644 index 000000000..2d90faac6 --- /dev/null +++ b/app/features/tournament-match/TournamentMatchRepository.server.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as TournamentMatchRepository from "./TournamentMatchRepository.server"; + +const createTournament = () => + db + .insertInto("Tournament") + .values({ + mapPickingStyle: "TO", + settings: JSON.stringify({ bracketProgression: [] }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createTeam = (tournamentId: number, name: string) => + db + .insertInto("TournamentTeam") + .values({ + tournamentId, + name, + inviteCode: `inv-${tournamentId}-${name}`, + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createStage = (tournamentId: number, name: string, number: number) => + db + .insertInto("TournamentStage") + .values({ + tournamentId, + name, + number, + type: "double_elimination", + settings: "{}", + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createGroup = (stageId: number, number: number) => + db + .insertInto("TournamentGroup") + .values({ stageId, number }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createRound = (stageId: number, groupId: number, number: number) => + db + .insertInto("TournamentRound") + .values({ + stageId, + groupId, + number, + maps: JSON.stringify({ count: 3, type: "BEST_OF" }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createMatch = async (args: { + stageId: number; + groupId: number; + roundId: number; + number: number; + teamOneId: number; + teamTwoId: number; +}) => { + const match = await db + .insertInto("TournamentMatch") + .values({ + stageId: args.stageId, + groupId: args.groupId, + roundId: args.roundId, + number: args.number, + status: 4, + opponentOne: JSON.stringify({ id: args.teamOneId, score: 2 }), + opponentTwo: JSON.stringify({ id: args.teamTwoId, score: 0 }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .insertInto("TournamentMatchGameResult") + .values({ + matchId: match.id, + mode: "SZ", + number: 1, + reporterId: 1, + source: "TO", + stageId: 1, + winnerTeamId: args.teamOneId, + }) + .returning("id") + .executeTakeFirstOrThrow() + .then((result) => + db + .insertInto("TournamentMatchGameResultParticipant") + .values([ + { + matchGameResultId: result.id, + userId: 1, + tournamentTeamId: args.teamOneId, + }, + { + matchGameResultId: result.id, + userId: 2, + tournamentTeamId: args.teamTwoId, + }, + ]) + .execute(), + ); + + return match; +}; + +describe("findByTournamentTeamId", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("preserves stage order: matches from an earlier stage come first even when later stage has lower group numbers", async () => { + // Tournament with two stages. The first stage has a high group number + // (think: round-robin pool 8) and the second stage has group number 1 + // (think: DE bracket winners). The team page should show stage 1's + // matches first, then stage 2's. + const tournament = await createTournament(); + const teamA = await createTeam(tournament.id, "A"); + const teamB = await createTeam(tournament.id, "B"); + + // Insert team members so we have someone to attribute results to + for (const userId of [1, 2]) { + await db + .insertInto("TournamentTeamMember") + .values({ tournamentTeamId: teamA.id, userId, role: "OWNER" }) + .execute(); + await db + .insertInto("TournamentTeamMember") + .values({ tournamentTeamId: teamB.id, userId, role: "OWNER" }) + .execute(); + } + + const stage1 = await createStage(tournament.id, "Stage 1", 1); + const stage1Group = await createGroup(stage1.id, 8); + const stage1Round = await createRound(stage1.id, stage1Group.id, 1); + const stage1Match = await createMatch({ + stageId: stage1.id, + groupId: stage1Group.id, + roundId: stage1Round.id, + number: 1, + teamOneId: teamA.id, + teamTwoId: teamB.id, + }); + + const stage2 = await createStage(tournament.id, "Stage 2", 2); + const stage2Group = await createGroup(stage2.id, 1); + const stage2Round = await createRound(stage2.id, stage2Group.id, 1); + const stage2Match = await createMatch({ + stageId: stage2.id, + groupId: stage2Group.id, + roundId: stage2Round.id, + number: 1, + teamOneId: teamA.id, + teamTwoId: teamB.id, + }); + + const result = await TournamentMatchRepository.findByTournamentTeamId( + teamA.id, + ); + + expect(result.map((s) => s.tournamentMatchId)).toEqual([ + stage1Match.id, + stage2Match.id, + ]); + }); +}); diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts new file mode 100644 index 000000000..24f230816 --- /dev/null +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -0,0 +1,244 @@ +import { sql } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import { TournamentMatchStatus } from "~/db/tables"; +import type { Unwrapped } from "~/utils/types"; + +const opponentOneId = sql`"TournamentMatch"."opponentOne" ->> '$.id'`; +const opponentTwoId = sql`"TournamentMatch"."opponentTwo" ->> '$.id'`; +const opponentOneScore = sql< + number | null +>`"TournamentMatch"."opponentOne" ->> '$.score'`; +const opponentTwoScore = sql< + number | null +>`"TournamentMatch"."opponentTwo" ->> '$.score'`; + +export type FindMatchById = NonNullable>; +export async function findMatchById(id: number) { + const row = await db + .selectFrom("TournamentMatch") + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .innerJoin( + "TournamentRound", + "TournamentRound.id", + "TournamentMatch.roundId", + ) + .innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId") + .select(({ eb }) => [ + "TournamentMatch.id", + "TournamentMatch.groupId", + "TournamentMatch.opponentOne", + "TournamentMatch.opponentTwo", + "TournamentMatch.chatCode", + "TournamentMatch.startedAt", + "TournamentMatch.status", + "Tournament.mapPickingStyle", + "TournamentRound.id as roundId", + "TournamentRound.maps as roundMaps", + jsonArrayFrom( + eb + .selectFrom("TournamentTeamMember") + .innerJoin("User", "User.id", "TournamentTeamMember.userId") + .select([ + "User.id", + "User.username", + "TournamentTeamMember.tournamentTeamId", + sql< + string | null + >`coalesce("TournamentTeamMember"."inGameName", "User"."inGameName")`.as( + "inGameName", + ), + "User.discordId", + "User.customUrl", + "User.discordAvatar", + "User.pronouns", + ]) + .where(({ or, eb: innerEb }) => + or([ + innerEb( + "TournamentTeamMember.tournamentTeamId", + "=", + opponentOneId, + ), + innerEb( + "TournamentTeamMember.tournamentTeamId", + "=", + opponentTwoId, + ), + ]), + ), + ).as("players"), + ]) + .where("TournamentMatch.id", "=", id) + .executeTakeFirst(); + + if (!row) return; + + return { + ...row, + bestOf: row.roundMaps.count, + }; +} + +export function findResultById(id: number) { + return db + .selectFrom("TournamentMatchGameResult") + .select([ + "TournamentMatchGameResult.id", + "TournamentMatchGameResult.opponentOnePoints", + "TournamentMatchGameResult.opponentTwoPoints", + "TournamentMatchGameResult.winnerTeamId", + ]) + .where("TournamentMatchGameResult.id", "=", id) + .executeTakeFirst(); +} + +export async function userParticipationByTournamentId(tournamentId: number) { + return db + .with("playerMatches", (db) => + db + .selectFrom("TournamentMatchGameResultParticipant as Participant") + .innerJoin( + "TournamentMatchGameResult as GameResult", + "GameResult.id", + "Participant.matchGameResultId", + ) + .innerJoin("TournamentMatch as Match", "Match.id", "GameResult.matchId") + .innerJoin("TournamentStage as Stage", "Stage.id", "Match.stageId") + .select(["Participant.userId", "GameResult.matchId"]) + .where("Stage.tournamentId", "=", tournamentId) + .distinct(), + ) + .selectFrom("playerMatches") + .select(({ fn, ref }) => [ + "playerMatches.userId", + fn + .agg("json_group_array", [ref("playerMatches.matchId")]) + .as("matchIds"), + ]) + .groupBy("playerMatches.userId") + .execute(); +} + +export type FindByTournamentTeamIdItem = Unwrapped< + typeof findByTournamentTeamId +>; +export function findByTournamentTeamId(tournamentTeamId: number) { + return db + .selectFrom("TournamentMatch") + .innerJoin( + "TournamentRound", + "TournamentRound.id", + "TournamentMatch.roundId", + ) + .innerJoin( + "TournamentGroup", + "TournamentGroup.id", + "TournamentMatch.groupId", + ) + .innerJoin("TournamentTeam as otherTeam", (join) => + join.on((eb) => + eb.or([ + eb.and([ + eb(opponentOneId, "!=", tournamentTeamId), + eb(opponentOneId, "=", eb.ref("otherTeam.id")), + ]), + eb.and([ + eb(opponentTwoId, "!=", tournamentTeamId), + eb(opponentTwoId, "=", eb.ref("otherTeam.id")), + ]), + ]), + ), + ) + .select(({ eb }) => [ + "TournamentMatch.id as tournamentMatchId", + opponentOneScore.as("opponentOneScore"), + opponentTwoScore.as("opponentTwoScore"), + "otherTeam.name as otherTeamName", + "otherTeam.id as otherTeamId", + "TournamentRound.number as roundNumber", + "TournamentRound.stageId", + "TournamentGroup.number as groupNumber", + jsonArrayFrom( + eb + .selectFrom("TournamentMatchGameResult") + .select([ + "TournamentMatchGameResult.mode", + "TournamentMatchGameResult.stageId", + "TournamentMatchGameResult.source", + sql`"TournamentMatchGameResult"."winnerTeamId" = ${tournamentTeamId}`.as( + "wasWinner", + ), + ]) + .whereRef( + "TournamentMatchGameResult.matchId", + "=", + "TournamentMatch.id", + ) + .orderBy("TournamentMatchGameResult.number", "asc"), + ).as("matches"), + jsonArrayFrom( + eb + .selectFrom("User") + .innerJoin( + "TournamentMatchGameResultParticipant", + "TournamentMatchGameResultParticipant.userId", + "User.id", + ) + .innerJoin( + "TournamentMatchGameResult", + "TournamentMatchGameResult.id", + "TournamentMatchGameResultParticipant.matchGameResultId", + ) + .innerJoin("TournamentTeamMember", (join) => + join + .onRef("TournamentTeamMember.userId", "=", "User.id") + .onRef( + "TournamentTeamMember.tournamentTeamId", + "=", + "otherTeam.id", + ), + ) + .select([ + "User.id", + "User.username", + "User.discordAvatar", + "User.discordId", + "User.customUrl", + ]) + .whereRef( + "TournamentMatchGameResult.matchId", + "=", + "TournamentMatch.id", + ) + .distinct(), + ).as("players"), + ]) + .where((eb) => + eb.or([ + eb(opponentOneId, "=", tournamentTeamId), + eb(opponentTwoId, "=", tournamentTeamId), + ]), + ) + .where("TournamentMatch.status", ">=", TournamentMatchStatus.Completed) + .where((eb) => + eb.exists( + eb + .selectFrom("TournamentMatchGameResult") + .select("TournamentMatchGameResult.id") + .whereRef( + "TournamentMatchGameResult.matchId", + "=", + "TournamentMatch.id", + ), + ), + ) + .orderBy("TournamentRound.stageId", "asc") + .orderBy("TournamentGroup.number", "asc") + .orderBy("TournamentRound.number", "asc") + .execute(); +} diff --git a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts similarity index 87% rename from app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts rename to app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 33967e7fb..21dfaf7ba 100644 --- a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -3,10 +3,27 @@ import { sql } from "~/db/sql"; import { TournamentMatchStatus } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server"; -import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server"; +import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import { + clearTournamentDataCache, + type TournamentDataTeam, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { deletePickBanEvent } from "~/features/tournament-bracket/queries/deletePickBanEvent.server"; +import { + matchPageParamsSchema, + matchSchema, +} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { + tournamentTeamToActiveRosterUserIds, + tournamentWebsocketRoom, +} from "~/features/tournament-bracket/tournament-bracket-utils"; +import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { @@ -16,36 +33,22 @@ import { parseRequestPayload, } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { getServerTournamentManager } from "../core/brackets-manager/manager.server"; import { executeRoll } from "../core/executeRoll.server"; import { resolveMapList } from "../core/mapList.server"; -import * as PickBan from "../core/PickBan"; -import { - clearTournamentDataCache, - type TournamentDataTeam, - tournamentFromDB, -} from "../core/Tournament.server"; import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.server"; import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server"; -import { deletePickBanEvent } from "../queries/deletePickBanEvent.server"; import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server"; import { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server"; import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server"; import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server"; import type { FindMatchById } from "../TournamentMatchRepository.server"; -import { - matchPageParamsSchema, - matchSchema, -} from "../tournament-bracket-schemas.server"; import { isSetOverByScore, matchEndedEarly, matchIsLocked, tournamentMatchWebsocketRoom, - tournamentTeamToActiveRosterUserIds, - tournamentWebsocketRoom, -} from "../tournament-bracket-utils"; +} from "../tournament-match-utils"; export const action: ActionFunction = async ({ params, request }) => { const user = requireUser(); @@ -151,6 +154,7 @@ export const action: ActionFunction = async ({ params, request }) => { errorToastIfFalsy( !data.points || + data.points[0] === data.points[1] || (scoreToIncrement() === 0 && data.points[0] > data.points[1]) || (scoreToIncrement() === 1 && data.points[1] > data.points[0]), "Points are invalid (winner must have more points than loser)", @@ -351,6 +355,11 @@ export const action: ActionFunction = async ({ params, request }) => { } })(); + await ReportedWeaponRepository.deleteByMapIndexTournament({ + tournamentMatchId: matchId, + mapIndex: data.position, + }); + emitMatchUpdate = true; emitTournamentUpdate = true; @@ -370,6 +379,18 @@ export const action: ActionFunction = async ({ params, request }) => { "Invalid roster length", ); + const teamOne = tournament.teamById(match.opponentOne!.id!)!; + const teamTwo = tournament.teamById(match.opponentTwo!.id!)!; + errorToastIfFalsy( + data.rosters[0].every((userId) => + teamOne.members.some((m) => m.userId === userId), + ) && + data.rosters[1].every((userId) => + teamTwo.members.some((m) => m.userId === userId), + ), + "Invalid roster", + ); + const hadPoints = typeof result.opponentOnePoints === "number"; const willHavePoints = typeof data.points?.[0] === "number"; errorToastIfFalsy( @@ -386,15 +407,15 @@ export const action: ActionFunction = async ({ params, request }) => { ); } - if (result.opponentOnePoints! > result.opponentTwoPoints!) { + if (data.points[0] === 100) { errorToastIfFalsy( - data.points[0] > data.points[1], - "Winner must have more points than loser", + result.winnerTeamId === match.opponentOne!.id, + "KO winner must match the result winner", ); - } else { + } else if (data.points[1] === 100) { errorToastIfFalsy( - data.points[0] < data.points[1], - "Winner must have more points than loser", + result.winnerTeamId === match.opponentTwo!.id, + "KO winner must match the result winner", ); } } @@ -471,6 +492,8 @@ export const action: ActionFunction = async ({ params, request }) => { const isModeAction = actionType === "MODE_PICK" || actionType === "MODE_BAN"; + const isCustomStageBan = + match.roundMaps.pickBan === "CUSTOM" && actionType === "BAN"; const pickBanLegalityArgs = { results, @@ -487,6 +510,7 @@ export const action: ActionFunction = async ({ params, request }) => { }; if (isModeAction) { + errorToastIfFalsy(data.mode, "Mode is required for mode actions"); errorToastIfFalsy( PickBan.isModeLegal({ mode: data.mode, @@ -494,10 +518,22 @@ export const action: ActionFunction = async ({ params, request }) => { }), "Illegal mode", ); - } else { + } else if (isCustomStageBan) { errorToastIfFalsy( typeof data.stageId === "number", - "Stage is required for map actions", + "Stage is required for stage ban", + ); + errorToastIfFalsy( + PickBan.isStageLegal({ + stageId: data.stageId, + ...pickBanLegalityArgs, + }), + "Illegal stage ban", + ); + } else { + errorToastIfFalsy( + typeof data.stageId === "number" && data.mode, + "Stage and mode are required for map actions", ); errorToastIfFalsy( PickBan.isLegal({ @@ -518,7 +554,7 @@ export const action: ActionFunction = async ({ params, request }) => { authorId: user.id, matchId: match.id, stageId: isModeAction ? null : data.stageId!, - mode: data.mode, + mode: isCustomStageBan ? null : (data.mode ?? null), number: currentPickBanEvents.length + 1, type: eventType, }); @@ -713,9 +749,11 @@ export const action: ActionFunction = async ({ params, request }) => { manager.update.match({ id: match.id, opponent1: { + score: match.opponentOne?.score, result: winnerTeamId === match.opponentOne!.id ? "win" : "loss", }, opponent2: { + score: match.opponentTwo?.score, result: winnerTeamId === match.opponentTwo!.id ? "win" : "loss", }, }); @@ -731,6 +769,37 @@ export const action: ActionFunction = async ({ params, request }) => { break; } + case "REPORT_WEAPON": { + const isMemberOfATeamInTheMatch = match.players.some( + (p) => p.id === user.id, + ); + errorToastIfFalsy(isMemberOfATeamInTheMatch, "Unauthorized"); + errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + + await ReportedWeaponRepository.upsertOneTournament({ + tournamentMatchId: matchId, + mapIndex: data.mapIndex, + userId: user.id, + weaponSplId: data.weaponSplId, + }); + + break; + } + case "UNDO_WEAPON_REPORT": { + const isMemberOfATeamInTheMatch = match.players.some( + (p) => p.id === user.id, + ); + errorToastIfFalsy(isMemberOfATeamInTheMatch, "Unauthorized"); + errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + + await ReportedWeaponRepository.deleteByUserMapIndexTournament({ + tournamentMatchId: matchId, + userId: user.id, + mapIndex: data.mapIndex, + }); + + break; + } default: { assertUnreachable(data); } diff --git a/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx b/app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx similarity index 87% rename from app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx rename to app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx index c9de36a1a..c5b7e3ffe 100644 --- a/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx +++ b/app/features/tournament-match/components/OrganizerMatchMapListDialog.tsx @@ -6,26 +6,21 @@ import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { nullFilledArray } from "~/utils/arrays"; -import type { SerializeFrom } from "~/utils/remix"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; -import { pickInfoText } from "../tournament-bracket-utils"; +import { useMatch } from "../match-page-context"; +import { pickInfoText } from "../tournament-match-utils"; export function OrganizerMatchMapListDialog({ data, }: { - data: SerializeFrom; + data: TournamentMatchLoaderData; }) { const { t } = useTranslation(["game-misc", "tournament"]); const [isOpen, setIsOpen] = React.useState(false); const tournament = useTournament(); - - const teamOne = data.match.opponentOne?.id - ? tournament.teamById(data.match.opponentOne.id) - : undefined; - const teamTwo = data.match.opponentTwo?.id - ? tournament.teamById(data.match.opponentTwo.id) - : undefined; - + const { + teams: [teamOne, teamTwo], + } = useMatch(); if (!teamOne || !teamTwo) return null; const bannedMaps = data.mapList?.filter( diff --git a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx new file mode 100644 index 000000000..5d6479e26 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx @@ -0,0 +1,175 @@ +import { useFetcher } from "react-router"; +import { + MatchActionPickBanTab, + type PickBanMapOption, +} from "~/components/match-page/MatchActionPickBanTab"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; + +type FromIndicator = NonNullable; + +export function TournamentMatchActionPickBanTab({ + data, + teams, + turnOfResult, +}: { + data: TournamentMatchLoaderData; + teams: [TournamentDataTeam, TournamentDataTeam]; + turnOfResult: PickBan.TurnOfResult; +}) { + const user = useUser(); + const tournament = useTournament(); + const fetcher = useFetcher(); + + const pickerTeamId = turnOfResult.teamId; + const pickingTeam = teams.find((team) => team.id === pickerTeamId)!; + + const canPickBan = + tournament.isOrganizer(user) || + tournament.ownedTeamByUser(user)?.id === pickerTeamId; + + const pickBanMapPool = PickBan.mapsListWithLegality({ + toSetMapPool: tournament.ctx.toSetMapPool, + maps: data.match.roundMaps, + mapList: data.mapList, + teams, + tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + pickerTeamId, + results: data.results, + pickBanEvents: data.pickBanEvents, + }); + + const isModeAction = + turnOfResult.action === "MODE_PICK" || turnOfResult.action === "MODE_BAN"; + const isCustomStageBan = + data.match.roundMaps?.pickBan === "CUSTOM" && turnOfResult.action === "BAN"; + const sharedActionType: "PICK" | "BAN" = + turnOfResult.action === "PICK" || turnOfResult.action === "MODE_PICK" + ? "PICK" + : "BAN"; + + const teamMemberOfByUser = tournament.teamMemberOfByUser(user); + const isPartOfTheMatch = teams.some( + (team) => team.id === teamMemberOfByUser?.id, + ); + + const resolveFrom = ( + stageId: StageId, + mode?: ModeShort, + ): FromIndicator | undefined => { + if (!isPartOfTheMatch) return undefined; + + const teamOneHas = teams[0].mapPool?.some( + (map) => map.stageId === stageId && (!mode || map.mode === mode), + ); + const teamTwoHas = teams[1].mapPool?.some( + (map) => map.stageId === stageId && (!mode || map.mode === mode), + ); + + if (teamOneHas && teamTwoHas) return "BOTH"; + if (teamOneHas) { + return teams[0].id === teamMemberOfByUser?.id ? "US" : "THEM"; + } + if (teamTwoHas) { + return teams[1].id === teamMemberOfByUser?.id ? "US" : "THEM"; + } + return undefined; + }; + + const options = buildPickBanOptions({ + pickBanMapPool, + mapList: data.mapList, + isModeAction, + isCustomStageBan, + isBan2: data.match.roundMaps?.pickBan === "BAN_2", + resolveFrom, + }); + + return ( + { + fetcher.submit( + { + _action: "BAN_PICK", + ...(map.mode != null ? { mode: map.mode } : {}), + ...(map.stageId != null ? { stageId: String(map.stageId) } : {}), + }, + { method: "post" }, + ); + }} + /> + ); +} + +function buildPickBanOptions({ + pickBanMapPool, + mapList, + isModeAction, + isCustomStageBan, + isBan2, + resolveFrom, +}: { + pickBanMapPool: ReturnType; + mapList: TournamentMatchLoaderData["mapList"]; + isModeAction: boolean; + isCustomStageBan: boolean; + isBan2: boolean; + resolveFrom: ( + stageId: StageId, + mode?: ModeShort, + ) => FromIndicator | undefined; +}): PickBanMapOption[] { + const legal = pickBanMapPool.filter((map) => map.isLegal); + + if (isModeAction) { + const seen = new Set(); + const uniqueModes: PickBanMapOption[] = []; + for (const { mode } of legal) { + if (seen.has(mode)) continue; + seen.add(mode); + uniqueModes.push({ mode }); + } + return uniqueModes; + } + + if (isCustomStageBan) { + const seen = new Set(); + const uniqueStages: PickBanMapOption[] = []; + for (const { stageId } of legal) { + if (seen.has(stageId)) continue; + seen.add(stageId); + uniqueStages.push({ stageId, picker: resolveFrom(stageId) }); + } + return uniqueStages.sort((a, b) => a.stageId! - b.stageId!); + } + + return legal + .slice() + .sort((a, b) => { + const modeDiff = modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode); + if (modeDiff !== 0) return modeDiff; + return a.stageId - b.stageId; + }) + .map((map) => { + const nth = isBan2 + ? (mapList ?? []).findIndex( + (m) => m.stageId === map.stageId && m.mode === map.mode, + ) + 1 + : undefined; + return { + stageId: map.stageId, + mode: map.mode, + picker: resolveFrom(map.stageId, map.mode), + ...(nth ? { nth } : {}), + }; + }); +} diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx new file mode 100644 index 000000000..c626d0d32 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -0,0 +1,294 @@ +import clsx from "clsx"; +import { Undo2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { MatchActionTab } from "~/components/match-page/MatchActionTab"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponReport"; +import { WeaponReporter } from "~/components/match-page/WeaponReporter"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; +import type { CommonUser } from "~/utils/kysely.server"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import { useMatch } from "../match-page-context"; +import { isSetOverByScore } from "../tournament-match-utils"; + +export function TournamentMatchActionTab({ + data, + ownTeamId, +}: { + data: TournamentMatchLoaderData; + ownTeamId: number; +}) { + const { t } = useTranslation(["q"]); + const tournament = useTournament(); + const user = useUser(); + const reportFetcher = useFetcher(); + const undoFetcher = useFetcher(); + const { + teams: [teamOne, teamTwo], + scores, + scoreSum, + currentMap, + } = useMatch(); + + const weaponReport = useTournamentWeaponReport({ + data, + viewerUserId: user?.id, + }); + + if (!currentMap) { + return ( + + {weaponReport ? : null} + + ); + } + + if (!teamOne || !teamTwo) return null; + + const withPoints = tournament.bracketByIdxOrDefault( + tournament.matchIdToBracketIdx(data.match.id) ?? 0, + ).collectResultsWithPoints; + + const count = data.match.roundMaps.count; + const countType = data.match.roundMaps.type; + + const setEndingTeamIds: number[] = []; + if ( + isSetOverByScore({ + scores: [scores[0] + 1, scores[1]], + count, + countType, + }) + ) { + setEndingTeamIds.push(teamOne.id); + } + if ( + isSetOverByScore({ + scores: [scores[0], scores[1] + 1], + count, + countType, + }) + ) { + setEndingTeamIds.push(teamTwo.id); + } + + const setEnding = + setEndingTeamIds.length > 0 + ? { + ...buildSetEndingData({ + tournament, + teams: [teamOne, teamTwo], + scores, + results: data.results, + opponentOneId: teamOne.id, + }), + setEndingTeamIds, + } + : undefined; + + return ( + { + reportFetcher.submit( + { + _action: "REPORT_SCORE", + winnerTeamId: String(winnerId), + position: String(scoreSum), + ...(points ? { points: JSON.stringify(points) } : {}), + }, + { method: "post" }, + ); + }} + actionButtons={ + } + isPending={undoFetcher.state !== "idle"} + className={clsx({ invisible: scoreSum === 0 })} + onPress={() => { + undoFetcher.submit( + { + _action: "UNDO_REPORT_SCORE", + position: String(scoreSum - 1), + }, + { method: "post" }, + ); + }} + testId="undo-score-button" + > + {t("q:match.undoReport")} + + } + weaponReport={weaponReport ?? undefined} + /> + ); +} + +function useTournamentWeaponReport({ + data, + viewerUserId, +}: { + data: TournamentMatchLoaderData; + viewerUserId: number | undefined; +}) { + const playOrderMaps = (data.mapList ?? []).filter( + (m) => !m.bannedByTournamentTeamId, + ); + const reportedCount = data.results.length; + const weaponReportMaps = playOrderMaps + .slice(0, reportedCount + 1) + .map((m) => ({ stageId: m.stageId, mode: m.mode })); + + const pastReported = + data.reportedWeapons && viewerUserId !== undefined + ? data.reportedWeapons + .filter((w) => w.userId === viewerUserId) + .map((w) => ({ mapIndex: w.mapIndex, weaponSplId: w.weaponSplId })) + : []; + + const weaponReport = useMatchWeaponReport({ + maps: weaponReportMaps, + pastReported, + }); + + if (viewerUserId === undefined) return null; + + const isParticipant = data.match.players.some((p) => p.id === viewerUserId); + if (!isParticipant) return null; + + if (weaponReportMaps.length === 0) return null; + + return weaponReport; +} + +function buildSetEndingData({ + tournament, + teams, + scores, + results, + opponentOneId, +}: { + tournament: ReturnType; + teams: [ + NonNullable["teamById"]>>, + NonNullable["teamById"]>>, + ]; + scores: [number, number]; + results: TournamentMatchLoaderData["results"]; + opponentOneId: number; +}) { + const [teamOne, teamTwo] = teams; + + const memberToCommonUser = (m: { + userId: number; + username: string; + discordId: string; + discordAvatar: string | null; + customUrl: string | null; + }): CommonUser => ({ + id: m.userId, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + }); + + const teamOneMembersMap = new Map( + teamOne.members.map((m) => [m.userId, memberToCommonUser(m)]), + ); + const teamTwoMembersMap = new Map( + teamTwo.members.map((m) => [m.userId, memberToCommonUser(m)]), + ); + + const previousMaps = results.map((result) => { + const alphaParticipants: CommonUser[] = []; + const bravoParticipants: CommonUser[] = []; + + for (const p of result.participants) { + const user = + teamOneMembersMap.get(p.userId) ?? teamTwoMembersMap.get(p.userId); + if (!user) continue; + + if (p.tournamentTeamId === opponentOneId) { + alphaParticipants.push(user); + } else { + bravoParticipants.push(user); + } + } + + return { + stageId: result.stageId, + mode: result.mode, + timestamp: databaseTimestampToJavascriptTimestamp(result.createdAt), + winner: + result.winnerTeamId === opponentOneId + ? ("ALPHA" as const) + : ("BRAVO" as const), + rosters: { + alpha: alphaParticipants, + bravo: bravoParticipants, + }, + points: + result.opponentOnePoints != null && result.opponentTwoPoints != null + ? ([result.opponentOnePoints, result.opponentTwoPoints] as [ + number, + number, + ]) + : undefined, + }; + }); + + const activeRosterUsers = ( + team: NonNullable["teamById"]>>, + ): CommonUser[] => { + const activeIds = team.activeRosterUserIds; + const members = activeIds + ? team.members.filter((m) => activeIds.includes(m.userId)) + : team.members; + return members.map(memberToCommonUser); + }; + + return { + teams: { + alpha: { + name: teamOne.name, + avatar: tournament.tournamentTeamLogoSrc(teamOne) ?? undefined, + }, + bravo: { + name: teamTwo.name, + avatar: tournament.tournamentTeamLogoSrc(teamTwo) ?? undefined, + }, + }, + score: { alpha: scores[0], bravo: scores[1] }, + maps: previousMaps, + currentRosters: { + alpha: activeRosterUsers(teamOne), + bravo: activeRosterUsers(teamTwo), + }, + }; +} diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.module.css b/app/features/tournament-match/components/TournamentMatchAdminTab.module.css new file mode 100644 index 000000000..64bb85fa2 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.module.css @@ -0,0 +1,106 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-6); +} + +.castSection { + display: flex; + flex-direction: column; + gap: var(--s-4); + align-items: flex-start; +} + +.castLabelRow { + display: flex; + align-items: center; + gap: var(--s-2); + + & label { + text-box: trim-both cap alphabetic; + } +} + +.castEmptyHint { + color: var(--color-text-high); + font-size: var(--font-xs); + margin: 0; +} + +.lockRow { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.buttonRow { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + align-items: center; + justify-content: center; +} + +.editSection { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.resultList { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.resultRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + padding: var(--s-2) var(--s-3); + background-color: var(--color-bg-higher); + border-radius: var(--radius-box); +} + +.resultRowEditing { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: var(--s-3); + background-color: var(--color-bg-higher); + border-radius: var(--radius-box); +} + +.mapIndex { + font-weight: var(--weight-semi); + margin-right: var(--s-2); +} + +.winnerName { + color: var(--color-text-high); +} + +.rosterColumns { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--s-3); + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.teamFieldset { + border: 2px solid var(--color-border); + border-radius: var(--radius-box); + padding: var(--s-2); + padding-block-end: var(--s-2); + padding-inline: var(--s-2); + background-color: transparent; + + & legend { + padding: 0 var(--s-2); + font-weight: var(--weight-semi); + } +} diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx new file mode 100644 index 000000000..3318d1452 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx @@ -0,0 +1,574 @@ +import { Ban, Lock, LockOpen, RotateCcw, SquarePen } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Form, useFetcher } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouPopover } from "~/components/elements/Popover"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { toastQueue } from "~/components/elements/Toast"; +import { InfoPopover } from "~/components/InfoPopover"; +import { Label } from "~/components/Label"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { SubmitButton } from "~/components/SubmitButton"; +import { TournamentMatchStatus } from "~/db/tables"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import { useMatch } from "../match-page-context"; +import { OrganizerMatchMapListDialog } from "./OrganizerMatchMapListDialog"; +import styles from "./TournamentMatchAdminTab.module.css"; + +const NOT_CASTED_VALUE = "null"; + +export function TournamentMatchAdminTab({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const user = useUser(); + const tournament = useTournament(); + const { + teams: [teamOne, teamTwo], + } = useMatch(); + + const isOrganizer = tournament.isOrganizer(user); + const canReopen = + isOrganizer && + data.matchIsOver && + tournament.matchCanBeReopened(data.match.id); + const canEndSet = + isOrganizer && !data.matchIsOver && data.match.startedAt !== null; + + const topActionsVisible = !!teamOne && !!teamTwo; + const castSectionVisible = !data.matchIsOver; + const editScoresVisible = + isOrganizer && !!teamOne && !!teamTwo && data.results.length > 0; + + return ( + +
+ {topActionsVisible ? ( +
+ + {canReopen ? : null} + {canEndSet ? : null} +
+ ) : null} + {castSectionVisible ? ( + + ) : null} + {editScoresVisible ? ( + + ) : null} +
+
+ ); +} + +function AdminCastSection({ + matchId, + matchStatus, +}: { + matchId: number; + matchStatus: number; +}) { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + const { scoreSum } = useMatch(); + + const isMatchStarted = scoreSum > 0; + + const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? []; + const castedMatchesInfo = tournament.ctx.castedMatchesInfo; + const currentlyCastedOn = + castedMatchesInfo?.castedMatches.find((cm) => cm.matchId === matchId) + ?.twitchAccount ?? null; + const isLocked = + castedMatchesInfo?.lockedMatches?.some((lm) => lm.matchId === matchId) ?? + false; + + const canLock = + (matchStatus === TournamentMatchStatus.Locked || + matchStatus === TournamentMatchStatus.Waiting) && + !isLocked; + const canUnlock = !isMatchStarted && isLocked; + + return ( +
+
+ + {t("tournament:match.admin.castInfo")} +
+ {castTwitchAccounts.length === 0 ? ( +

+ {t("tournament:match.admin.castConfigureHint")} +

+ ) : ( + <> + + {canLock || canUnlock ? ( + + ) : null} + + )} +
+ ); +} + +function CastChannelChipRadio({ + matchId, + accounts, + currentlyCastedOn, +}: { + matchId: number; + accounts: string[]; + currentlyCastedOn: string | null; +}) { + const { t } = useTranslation(["tournament"]); + const fetcher = useFetcher(); + const previousStateRef = React.useRef(fetcher.state); + + React.useEffect(() => { + if ( + previousStateRef.current !== "idle" && + fetcher.state === "idle" && + !(fetcher.data as { error?: unknown } | undefined)?.error + ) { + toastQueue.add( + { + message: t("tournament:match.admin.castChannelUpdated"), + variant: "success", + }, + { timeout: 5000 }, + ); + } + previousStateRef.current = fetcher.state; + }, [fetcher.state, fetcher.data, t]); + + const selectedValue = currentlyCastedOn ?? NOT_CASTED_VALUE; + + const handleChange = (value: string) => { + if (value === selectedValue) return; + fetcher.submit( + { _action: "SET_AS_CASTED", twitchAccount: value }, + { method: "post" }, + ); + }; + + return ( + + + {t("tournament:match.admin.notCasted")} + + {accounts.map((account) => ( + + {account} + + ))} + + ); +} + +function LockToggleButton({ + isLocked, + twitchAccount, +}: { + isLocked: boolean; + twitchAccount: string | null; +}) { + const { t } = useTranslation(["tournament"]); + + return ( +
+ {isLocked ? ( + } + testId="cast-info-submit-button" + > + {t("tournament:match.admin.unlock")} + + ) : ( + <> + + } + isDisabled={!twitchAccount} + testId="cast-info-submit-button" + > + {t("tournament:match.admin.lockToBeCasted")} + + + )} + {t("tournament:match.admin.lockingInfo")} +
+ ); +} + +function ReopenMatchButton() { + const { t } = useTranslation(["tournament"]); + + return ( +
+ } + testId="reopen-match-button" + > + {t("tournament:match.action.reopenMatch")} + +
+ ); +} + +function EndSetPopover({ + teams, +}: { + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + const { t } = useTranslation(["tournament"]); + const [selectedWinner, setSelectedWinner] = React.useState< + number | null | undefined + >(undefined); + + return ( + } + > + {t("tournament:match.action.endSet")} + + } + > +
+
+ + + + + + + +
+ + + + + {t("tournament:match.action.confirmEndSet")} + +
+
+ ); +} + +function EditReportedScoresSection({ + data, + teams, +}: { + data: TournamentMatchLoaderData; + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + + const withPoints = tournament.bracketByIdxOrDefault( + tournament.matchIdToBracketIdx(data.match.id) ?? 0, + ).collectResultsWithPoints; + + return ( +
+ +
+ {data.results.map((result, index) => ( + + ))} +
+
+ ); +} + +function EditReportedScoreRow({ + index, + result, + teams, + withPoints, +}: { + index: number; + result: TournamentMatchLoaderData["results"][number]; + teams: [TournamentDataTeam, TournamentDataTeam]; + withPoints: boolean; +}) { + const { t } = useTranslation(["common", "tournament"]); + const tournament = useTournament(); + const fetcher = useFetcher(); + const [editing, setEditing] = React.useState(false); + const previousFetcherStateRef = React.useRef(fetcher.state); + + React.useEffect(() => { + if ( + previousFetcherStateRef.current !== "idle" && + fetcher.state === "idle" && + !(fetcher.data as { error?: unknown } | undefined)?.error + ) { + setEditing(false); + } + previousFetcherStateRef.current = fetcher.state; + }, [fetcher.state, fetcher.data]); + + const winnerName = + result.winnerTeamId === teams[0].id ? teams[0].name : teams[1].name; + const isKo = + result.opponentOnePoints === 100 || result.opponentTwoPoints === 100; + + if (!editing) { + return ( +
+
+ + {t("tournament:match.admin.mapNumber", { number: index + 1 })} + + + {isKo + ? t("tournament:match.admin.winnerWonKo", { + teamName: winnerName, + }) + : t("tournament:match.admin.winnerWon", { + teamName: winnerName, + })} + +
+ } + variant="outlined" + size="small" + onPress={() => setEditing(true)} + data-testid={`edit-result-${index}-button`} + > + {t("common:actions.edit")} + +
+ ); + } + + return ( + setEditing(false)} + index={index} + /> + ); +} + +function EditReportedScoreForm({ + fetcher, + result, + teams, + withPoints, + minMembersPerTeam, + onCancel, + index, +}: { + fetcher: ReturnType; + result: TournamentMatchLoaderData["results"][number]; + teams: [TournamentDataTeam, TournamentDataTeam]; + withPoints: boolean; + minMembersPerTeam: number; + onCancel: () => void; + index: number; +}) { + const { t } = useTranslation(["common", "q"]); + const [checkedPlayers, setCheckedPlayers] = React.useState< + [number[], number[]] + >(() => { + return [ + result.participants + .filter((p) => p.tournamentTeamId === teams[0].id) + .map((p) => p.userId), + result.participants + .filter((p) => p.tournamentTeamId === teams[1].id) + .map((p) => p.userId), + ]; + }); + const [isKO, setIsKO] = React.useState( + result.opponentOnePoints === 100 || result.opponentTwoPoints === 100, + ); + + const team0Won = result.winnerTeamId === teams[0].id; + const points: [number, number] = isKO + ? team0Won + ? [100, 0] + : [0, 100] + : [0, 0]; + + const formValid = checkedPlayers.every( + (team) => team.length === minMembersPerTeam, + ); + + const togglePlayer = (teamIdx: 0 | 1, userId: number) => { + setCheckedPlayers((prev) => { + const next: [number[], number[]] = [prev[0].slice(), prev[1].slice()]; + if (next[teamIdx].includes(userId)) { + next[teamIdx] = next[teamIdx].filter((id) => id !== userId); + } else { + next[teamIdx] = [...next[teamIdx], userId]; + } + return next; + }); + }; + + return ( + +
+ {teams.map((team, teamIdx) => ( +
+ {team.name} +
+ {team.members.map((member, memberIdx) => { + const checked = checkedPlayers[teamIdx as 0 | 1].includes( + member.userId, + ); + return ( + + ); + })} +
+
+ ))} +
+ + + {withPoints ? ( + <> + + + + ) : null} +
+ + {t("common:actions.save")} + + + {t("common:actions.cancel")} + +
+
+ ); +} diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx new file mode 100644 index 000000000..e8bc4d8e6 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx @@ -0,0 +1,442 @@ +import { differenceInMinutes } from "date-fns"; +import { Hourglass, Lock, MousePointerClick, Users, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { + IconBanner, + MatchBanner, + MatchBannerContainer, + MultiMatchBanner, +} from "~/components/match-page/MatchBanner"; +import bannerStyles from "~/components/match-page/MatchBanner.module.css"; +import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow"; +import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow"; +import type { TournamentRoundMaps } from "~/db/tables"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + isLeagueRoundLocked, + resolveLeagueRoundStartDate, +} from "~/features/tournament/tournament-utils"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import { useAutoRerender } from "~/hooks/useAutoRerender"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; +import type { ModeShort } from "~/modules/in-game-lists/types"; +import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types"; +import { databaseTimestampToDate } from "~/utils/dates"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import { useMatch } from "../match-page-context"; + +export function TournamentMatchBanner({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const { t } = useTranslation(["tournament"]); + const { formatDate } = useTimeFormat(); + const tournament = useTournament(); + const { currentMap, teamsMissingActiveRoster, matchIsLocked } = useMatch(); + + const opponentOne = data.match.opponentOne; + const opponentTwo = data.match.opponentTwo; + const isMissingTeam = !opponentOne?.id || !opponentTwo?.id; + + const leagueRoundLocked = isLeagueRoundLocked(tournament, data.match.roundId); + const leagueRoundStartDate = leagueRoundLocked + ? resolveLeagueRoundStartDate(tournament, data.match.roundId) + : null; + + const pickBanBanner = resolvePickBanBanner(data, tournament, t); + + const screenLegal = + tournament.ctx.settings.enableNoScreenToggle && + typeof data.noScreen === "boolean" + ? !data.noScreen + : undefined; + + const activeRosterByTeamId = (tournamentTeamId: number) => { + const team = tournament.teamById(tournamentTeamId); + if (!team) return null; + + const activeRosterUserIds = team.activeRosterUserIds; + if (!activeRosterUserIds?.length) return null; + + return team.members + .filter((member) => activeRosterUserIds.includes(member.userId)) + .map((member) => ({ ...member, id: member.userId })); + }; + + return ( + + + {leagueRoundLocked ? ( + } + header={t("tournament:match.leagueLocked.header")} + subtitle={ + leagueRoundStartDate + ? t("tournament:match.leagueLocked.subtitle", { + date: formatDate(leagueRoundStartDate, { + day: "numeric", + month: "numeric", + year: "numeric", + }), + }) + : undefined + } + /> + ) : matchIsLocked ? ( + } + header={t("tournament:match.locked.header")} + subtitle={t("tournament:match.locked.subtitle")} + screenLegal={screenLegal} + /> + ) : isMissingTeam ? ( + } + header={t("tournament:match.waitingForTeams.header")} + subtitle={t("tournament:match.waitingForTeams.subtitle")} + /> + ) : teamsMissingActiveRoster.length > 0 ? ( + } + header={t("tournament:match.activeRosterMissing.header")} + subtitle={t("tournament:match.activeRosterMissing.subtitle", { + teams: teamsMissingActiveRoster.join(" & "), + })} + screenLegal={screenLegal} + testId="active-roster-needed-text" + /> + ) : data.matchIsOver ? ( + result.stageId)} + /> + ) : pickBanBanner ? ( + + ) : currentMap ? ( + + + + ) : null} + + + ); +} + +function TournamentMatchBannerTopRow({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const tournament = useTournament(); + const currentTime = useAutoRerender("ten seconds"); + const { scores } = useMatch(); + + if ( + !data.match.startedAt || + !data.match.opponentOne || + !data.match.opponentTwo + ) + return null; + + const totalMinutes = differenceInMinutes( + currentTime, + databaseTimestampToDate(data.match.startedAt), + ); + + const currentMinutes = resolveCurrentMinutes({ + data, + tournament, + currentTime, + }); + + return ( + + ); +} + +function CurrentMapPickInfo({ + source, + results, + opponentIds, + pickBan, +}: { + source: TournamentMaplistSource; + results: Array<{ winnerTeamId: number }>; + opponentIds: [number, number] | null; + pickBan: TournamentRoundMaps["pickBan"] | null; +}) { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + + const picker = resolveCurrentMapPicker({ + source, + results, + opponentIds, + pickBan, + }); + if (!picker) return null; + + const team = tournament.teamById(picker.teamId); + if (!team) return null; + + const text = t( + picker.kind === "COUNTERPICK" + ? "tournament:pickInfo.counterpickedBy" + : "tournament:pickInfo.pickedBy", + { teamName: team.name }, + ); + + return ( + + + + } + > + {text} + + ); +} + +function resolveCurrentMapPicker({ + source, + results, + opponentIds, + pickBan, +}: { + source: TournamentMaplistSource; + results: Array<{ winnerTeamId: number }>; + opponentIds: [number, number] | null; + pickBan: TournamentRoundMaps["pickBan"] | null; +}): { teamId: number; kind: "PICK" | "COUNTERPICK" } | null { + if (!opponentIds) return null; + + if (typeof source === "number") { + if (!opponentIds.includes(source)) return null; + return { teamId: source, kind: "PICK" }; + } + + if ( + source === "COUNTERPICK" && + (pickBan === "COUNTERPICK" || pickBan === "COUNTERPICK_MODE_REPEAT_OK") + ) { + const lastResult = results[results.length - 1]; + if (!lastResult) return null; + const counterpickerId = opponentIds.find( + (id) => id !== lastResult.winnerTeamId, + ); + if (counterpickerId === undefined) return null; + return { teamId: counterpickerId, kind: "COUNTERPICK" }; + } + + return null; +} + +function resolveCurrentMinutes({ + data, + tournament, + currentTime, +}: { + data: TournamentMatchLoaderData; + tournament: ReturnType; + currentTime: Date; +}): number { + const opponentOneId = data.match.opponentOne?.id; + const opponentTwoId = data.match.opponentTwo?.id; + if (!opponentOneId || !opponentTwoId) return 0; + if (!data.match.roundMaps?.pickBan) return 0; + + const teamOne = tournament.teamById(opponentOneId); + const teamTwo = tournament.teamById(opponentTwoId); + if (!teamOne || !teamTwo) return 0; + + const teams: [PickBan.PickBanTeam, PickBan.PickBanTeam] = [ + { id: teamOne.id, seed: teamOne.seed }, + { id: teamTwo.id, seed: teamTwo.seed }, + ]; + + const currentTurn = PickBan.turnOf({ + results: data.results, + maps: data.match.roundMaps, + teams, + mapList: data.mapList, + pickBanEventCount: data.pickBanEventCount, + }); + if (!currentTurn) return 0; + + const sessionStart = PickBan.currentTurnSessionStartedAt({ + currentTurn, + events: data.pickBanEvents, + results: data.results, + matchStartedAt: data.match.startedAt, + maps: data.match.roundMaps, + teams, + }); + if (sessionStart == null) return 0; + + return Math.max( + 0, + differenceInMinutes(currentTime, databaseTimestampToDate(sessionStart)), + ); +} + +function resolvePickBanBanner( + data: TournamentMatchLoaderData, + tournament: ReturnType, + t: ReturnType>["t"], +): { icon: React.ReactNode; header: string; subtitle: string } | null { + if (data.matchIsOver) return null; + if (!data.match.roundMaps?.pickBan) return null; + + const opponentOneId = data.match.opponentOne?.id; + const opponentTwoId = data.match.opponentTwo?.id; + if (!opponentOneId || !opponentTwoId) return null; + + const teamOne = tournament.teamById(opponentOneId); + const teamTwo = tournament.teamById(opponentTwoId); + if (!teamOne || !teamTwo) return null; + + const turnOfResult = PickBan.turnOf({ + results: data.results, + maps: data.match.roundMaps, + teams: [ + { id: teamOne.id, seed: teamOne.seed }, + { id: teamTwo.id, seed: teamTwo.seed }, + ], + mapList: data.mapList, + pickBanEventCount: data.pickBanEventCount, + }); + if (!turnOfResult) return null; + + const pickingTeam = turnOfResult.teamId === teamOne.id ? teamOne : teamTwo; + + const isCustom = data.match.roundMaps.pickBan === "CUSTOM"; + const isCounterpick = + data.match.roundMaps.pickBan === "COUNTERPICK" || + data.match.roundMaps.pickBan === "COUNTERPICK_MODE_REPEAT_OK"; + + const stepCounter = + isCustom && turnOfResult.stepTotal && turnOfResult.stepTotal > 1 + ? ` (${turnOfResult.stepCurrent}/${turnOfResult.stepTotal})` + : ""; + + const header = (() => { + if (isCounterpick) return t("tournament:pickBan.counterpick"); + switch (turnOfResult.action) { + case "PICK": + return t("tournament:pickBan.pickMap") + stepCounter; + case "BAN": + return t("tournament:pickBan.banMap") + stepCounter; + case "MODE_PICK": + return t("tournament:pickBan.pickMode") + stepCounter; + case "MODE_BAN": + return t("tournament:pickBan.banMode") + stepCounter; + default: + return ""; + } + })(); + + if (!header) return null; + + const isBan = + turnOfResult.action === "BAN" || turnOfResult.action === "MODE_BAN"; + + return { + icon: isBan ? : , + header, + subtitle: t("tournament:pickBan.waitingFor", { + teamName: pickingTeam.name, + }), + }; +} + +function resolveBannerGames({ + data, + opponentOneId, +}: { + data: TournamentMatchLoaderData; + opponentOneId: number | null | undefined; +}): Array<{ mode: ModeShort | null; winner?: "ALPHA" | "BRAVO" }> { + const playedAndScheduled = + data.mapList?.map((map, i) => { + const result = data.results.at(i); + const winner = result + ? result.winnerTeamId === opponentOneId + ? ("ALPHA" as const) + : ("BRAVO" as const) + : undefined; + + return { + mode: map.mode as ModeShort | null, + winner, + }; + }) ?? []; + + if (data.matchIsOver) return playedAndScheduled; + + const placeholderCount = Math.max( + 0, + data.match.roundMaps.count - playedAndScheduled.length, + ); + + return [ + ...playedAndScheduled, + ...Array.from({ length: placeholderCount }, () => ({ + mode: null, + })), + ]; +} diff --git a/app/features/tournament-match/components/TournamentMatchHeader.tsx b/app/features/tournament-match/components/TournamentMatchHeader.tsx new file mode 100644 index 000000000..4b495bafc --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchHeader.tsx @@ -0,0 +1,42 @@ +import { ArrowLeft } from "lucide-react"; +import { LinkButton } from "~/components/elements/Button"; +import { MatchPageHeader } from "~/components/match-page/MatchPageHeader"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { tournamentBracketsPage } from "~/utils/urls"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; + +export function TournamentMatchHeader({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const tournament = useTournament(); + + const { bracketName, roundName } = tournament.matchContextNamesById( + data.match.id, + ); + + return ( + } + testId="back-to-bracket-button" + > + Back to bracket + + } + > + {roundName} + + ); +} diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx new file mode 100644 index 000000000..54b9cf462 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -0,0 +1,479 @@ +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import { MatchJoinTab } from "~/components/match-page/MatchJoinTab"; +import { MatchResultTab } from "~/components/match-page/MatchResultTab"; +import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; +import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs"; +import type { + TimelineMap, + TimelinePickBanEvent, +} from "~/components/match-page/MatchTimeline"; +import { resolveRoomPass } from "~/components/match-page/utils"; +import { useUser } from "~/features/auth/core/user"; +import { + resolveActiveRoomLink, + useConfirmRoom, +} from "~/features/chat/room-link-utils"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import { + groupNumberToLetters, + tournamentTeamToActiveRosterUserIds, +} from "~/features/tournament-bracket/tournament-bracket-utils"; +import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; +import { tournamentTeamPage } from "~/utils/urls"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import { type MatchPageTeam, useMatch } from "../match-page-context"; +import { resolveHostingTeam } from "../tournament-match-utils"; +import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab"; +import { TournamentMatchActionTab } from "./TournamentMatchActionTab"; +import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab"; + +export function TournamentMatchTabs({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const tournament = useTournament(); + const user = useUser(); + const { + teams: [teamOne, teamTwo], + scores, + tabs, + turnOfResult, + isPickBanStep, + } = useMatch(); + + // When waiting on team(s) only a subset of tabs can be rendered + if (!teamOne || !teamTwo) { + return tabs.length > 0 ? ( + + {tabs.includes(TAB_KEYS.ROSTERS) ? ( + + ) : null} + {tabs.includes(TAB_KEYS.ADMIN) ? ( + + ) : null} + + ) : null; + } + + const opponentOneId = teamOne.id; + const opponentTwoId = teamTwo.id; + const pickBanTeams: [MatchPageTeam, MatchPageTeam] = [teamOne, teamTwo]; + + const userTeamId = tournament.teamMemberOfByUser(user)?.id; + + const pickBanData = resolveTimelinePickBanData( + data, + opponentOneId, + pickBanTeams, + ); + const timelineMaps = resolveTimelineMaps( + data, + opponentOneId, + opponentTwoId, + ).map((m, i) => ({ ...m, pickedBy: pickBanData?.pickedBySlot.get(i) })); + + return ( + + {tabs.includes(TAB_KEYS.RESULT) ? ( + 0} + /> + ) : null} + {tabs.includes(TAB_KEYS.JOIN) ? ( + + ) : null} + + {tabs.includes(TAB_KEYS.ACTION) ? ( + isPickBanStep && turnOfResult ? ( + + ) : ( + + ) + ) : null} + {tabs.includes(TAB_KEYS.ADMIN) ? ( + + ) : null} + + ); +} + +function resolveTimelineTeams( + opponentOneId: number, + opponentTwoId: number, + tournament: ReturnType, +) { + const teamOne = tournament.teamById(opponentOneId); + const teamTwo = tournament.teamById(opponentTwoId); + + return { + alpha: { + name: teamOne?.name ?? "?", + avatar: teamOne + ? (tournament.tournamentTeamLogoSrc(teamOne) ?? undefined) + : undefined, + }, + bravo: { + name: teamTwo?.name ?? "?", + avatar: teamTwo + ? (tournament.tournamentTeamLogoSrc(teamTwo) ?? undefined) + : undefined, + }, + }; +} + +function resolveTimelineMaps( + data: TournamentMatchLoaderData, + opponentOneId: number, + opponentTwoId: number, +): TimelineMap[] { + const playerById = new Map(data.match.players.map((p) => [p.id, p])); + + const resolveRoster = ( + participants: (typeof data.results)[number]["participants"], + tournamentTeamId: number, + ) => + participants + .filter((p) => p.tournamentTeamId === tournamentTeamId) + .map((p) => playerById.get(p.userId)) + .filter((u): u is NonNullable => u != null) + .map((u) => ({ + id: u.id, + username: u.username, + discordId: u.discordId, + discordAvatar: u.discordAvatar, + customUrl: u.customUrl, + })); + + return data.results.map((result, mapIndex) => { + const hasPoints = + result.opponentOnePoints !== null && result.opponentTwoPoints !== null; + + const alphaRoster = resolveRoster(result.participants, opponentOneId); + const bravoRoster = resolveRoster(result.participants, opponentTwoId); + + const weaponFor = (userId: number) => + data.reportedWeapons?.find( + (w) => w.mapIndex === mapIndex && w.userId === userId, + )?.weaponSplId ?? null; + + const alphaWeapons = alphaRoster.map((u) => weaponFor(u.id)); + const bravoWeapons = bravoRoster.map((u) => weaponFor(u.id)); + const hasAnyWeapon = + alphaWeapons.some((w) => w !== null) || + bravoWeapons.some((w) => w !== null); + + return { + stageId: result.stageId, + mode: result.mode, + timestamp: databaseTimestampToJavascriptTimestamp(result.createdAt), + winner: + result.winnerTeamId === opponentOneId + ? ("ALPHA" as const) + : ("BRAVO" as const), + rosters: { + alpha: alphaRoster, + bravo: bravoRoster, + }, + weapons: hasAnyWeapon + ? { alpha: alphaWeapons, bravo: bravoWeapons } + : undefined, + points: hasPoints + ? ([result.opponentOnePoints, result.opponentTwoPoints] as [ + number, + number, + ]) + : undefined, + }; + }); +} + +function resolveTimelinePickBanData( + data: TournamentMatchLoaderData, + opponentOneId: number, + pickBanTeams: + | [ + ReturnType["teamById"]>, + ReturnType["teamById"]>, + ] + | undefined, +): + | { + rowsBySlot: TimelinePickBanEvent[][]; + pickedBySlot: Map; + } + | undefined { + const maps = data.match.roundMaps; + if (!maps?.pickBan || !pickBanTeams?.[0] || !pickBanTeams[1]) { + return undefined; + } + + const pickBanTeamsLite: [PickBan.PickBanTeam, PickBan.PickBanTeam] = [ + { id: pickBanTeams[0].id, seed: pickBanTeams[0].seed ?? 0 }, + { id: pickBanTeams[1].id, seed: pickBanTeams[1].seed ?? 0 }, + ]; + + const rowsBySlot: TimelinePickBanEvent[][] = Array.from( + { length: data.results.length + 1 }, + () => [], + ); + const pickedBySlot = new Map(); + + for (let i = 0; i < data.pickBanEvents.length; i++) { + const event = data.pickBanEvents[i]!; + if (event.type === "ROLL") continue; + + const teamId = PickBan.teamOfEvent({ + eventIndex: i, + maps, + teams: pickBanTeamsLite, + results: data.results, + }); + if (teamId === null) continue; + + const slot = slotOfEvent({ eventIndex: i, maps }); + const side: "ALPHA" | "BRAVO" = + teamId === opponentOneId ? "ALPHA" : "BRAVO"; + + const isMapPick = event.type === "PICK" && event.stageId !== null; + if (isMapPick && slot < data.results.length) { + pickedBySlot.set(slot, side); + continue; + } + + const isPick = event.type === "PICK" || event.type === "MODE_PICK"; + const kind: "PICK" | "BAN" = isPick ? "PICK" : "BAN"; + const bucketIndex = Math.min(slot, rowsBySlot.length - 1); + const bucket = rowsBySlot[bucketIndex]!; + const last = bucket[bucket.length - 1]; + const entry = { + stageId: event.stageId ?? undefined, + mode: event.mode ?? undefined, + }; + + if (last && last.kind === kind) { + (side === "ALPHA" ? last.alphaEntries : last.bravoEntries).push(entry); + } else { + bucket.push({ + kind, + alphaEntries: side === "ALPHA" ? [entry] : [], + bravoEntries: side === "BRAVO" ? [entry] : [], + }); + } + } + + return { rowsBySlot, pickedBySlot }; +} + +function slotOfEvent({ + eventIndex, + maps, +}: { + eventIndex: number; + maps: NonNullable; +}): number { + switch (maps.pickBan) { + case "BAN_2": + return 0; + case "COUNTERPICK": + case "COUNTERPICK_MODE_REPEAT_OK": + return eventIndex + 1; + case "CUSTOM": { + const customFlow = maps.customFlow; + if (!customFlow) return 0; + const preSetLength = customFlow.preSet.length; + const postGameLength = customFlow.postGame.length; + if (eventIndex < preSetLength) return 0; + if (postGameLength === 0) return 0; + return ( + PickBan.postGameCycleIndex({ + eventIndex, + preSetLength, + postGameLength, + }) + 1 + ); + } + default: + return 0; + } +} + +function TournamentMatchJoinTab({ data }: { data: TournamentMatchLoaderData }) { + const tournament = useTournament(); + const user = useUser(); + const { onConfirmRoom, isConfirming } = useConfirmRoom(); + const { + teams: [teamOne, teamTwo], + } = useMatch(); + if (!teamOne || !teamTwo) return null; + + const hostingTeam = resolveHostingTeam([teamOne, teamTwo]); + + 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]; + const bracketMatch = bracket?.data.match.find((m) => m.id === data.match.id); + const group = bracket?.data.group.find( + (g) => g.id === bracketMatch?.group_id, + ); + + const poolCode = tournament.resolvePoolCode({ + hostingTeamId: hostingTeam.id, + groupLetters: + group && bracket?.type === "round_robin" + ? groupNumberToLetters(group.number) + : undefined, + bracketNumber: + hasRoundRobin && bracket?.type !== "round_robin" + ? bracketIdx + 1 + : undefined, + }); + + const activeRoomLink = resolveActiveRoomLink({ + roomLinks: data.roomLinks, + freshnessCutoff: data.match.startedAt ?? 0, + viewerUserId: user?.id, + members: data.match.players, + }); + + return ( + + ); +} + +function TournamentMatchRosterTab({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + const user = useUser(); + const fetcher = useFetcher(); + const { + teams: [teamOne, teamTwo], + } = useMatch(); + + const tbdTeam = { defaultName: t("tournament:match.tbd"), members: [] }; + + return ( + + ); + + function rosterTeamData( + team: NonNullable>, + ) { + const subbedOut = + !data.matchIsOver && + team.activeRosterUserIds && + team.members.length > tournament.minMembersPerTeam + ? team.members + .filter((m) => !team.activeRosterUserIds!.includes(m.userId)) + .map((m) => m.userId) + : undefined; + + return { + team: { + id: team.id, + name: team.name, + url: tournamentTeamPage({ + tournamentId: tournament.ctx.id, + tournamentTeamId: team.id, + }), + avatar: tournament.tournamentTeamLogoSrc(team) ?? undefined, + }, + members: team.members.map((m) => ({ + id: m.userId, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + inGameName: m.inGameName, + })), + subbedOut, + }; + } + + function canEditSubbedOutForTeam( + team: NonNullable>, + ) { + if (data.matchIsOver) return false; + if (team.members.length <= tournament.minMembersPerTeam) return false; + + const isMemberOfTeam = team.members.some((m) => m.userId === user?.id); + return isMemberOfTeam || tournament.isOrganizer(user); + } + + function needsActiveRosterSelection( + team: NonNullable>, + ) { + if (!canEditSubbedOutForTeam(team)) return false; + return !tournamentTeamToActiveRosterUserIds( + team, + tournament.minMembersPerTeam, + ); + } + + function handleSubbedOutChange(teamId: number, subbedOut: number[]) { + const team = tournament.teamById(teamId); + if (!team) return; + + const activeRoster = team.members + .filter((m) => !subbedOut.includes(m.userId)) + .map((m) => m.userId); + + fetcher.submit( + { + _action: "SET_ACTIVE_ROSTER", + roster: JSON.stringify(activeRoster), + teamId: String(teamId), + }, + { method: "post" }, + ); + } +} diff --git a/app/features/tournament-bracket/core/executeRoll.server.ts b/app/features/tournament-match/core/executeRoll.server.ts similarity index 92% rename from app/features/tournament-bracket/core/executeRoll.server.ts rename to app/features/tournament-match/core/executeRoll.server.ts index e3d0f1bbd..3ee9f0465 100644 --- a/app/features/tournament-bracket/core/executeRoll.server.ts +++ b/app/features/tournament-match/core/executeRoll.server.ts @@ -1,12 +1,12 @@ import type { TournamentRoundMaps } from "~/db/tables"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; import type { ModeWithStage } from "~/modules/in-game-lists/types"; import invariant from "~/utils/invariant"; import { seededRandom } from "~/utils/random"; import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql"; import type { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; -import * as PickBan from "./PickBan"; -import type { TournamentDataTeam } from "./Tournament.server"; export async function executeRoll({ matchId, diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-match/core/mapList.server.ts similarity index 96% rename from app/features/tournament-bracket/core/mapList.server.ts rename to app/features/tournament-match/core/mapList.server.ts index e253c764b..047175592 100644 --- a/app/features/tournament-bracket/core/mapList.server.ts +++ b/app/features/tournament-match/core/mapList.server.ts @@ -1,7 +1,10 @@ import type { Tables, TournamentRoundMaps } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils"; +import type { Bracket } from "~/features/tournament-bracket/core/Bracket"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import { findMapPoolByTeamId } from "~/features/tournament-bracket/queries/findMapPoolByTeamId.server"; +import { findTieBreakerMapPoolByTournamentId } from "~/features/tournament-bracket/queries/findTieBreakerMapPoolByTournamentId.server"; import type { Round } from "~/modules/brackets-model"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { generateBalancedMapList } from "~/modules/tournament-map-list-generator/balanced-map-list"; @@ -14,9 +17,6 @@ import { syncCached } from "~/utils/cache.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; -import { findMapPoolByTeamId } from "../queries/findMapPoolByTeamId.server"; -import { findTieBreakerMapPoolByTournamentId } from "../queries/findTieBreakerMapPoolByTournamentId.server"; -import type { Bracket } from "./Bracket"; interface ResolveCurrentMapListArgs { tournamentId: number; diff --git a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts similarity index 77% rename from app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts rename to app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 06f8aed54..d568e10bc 100644 --- a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -3,27 +3,30 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server"; +import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { Status } from "~/modules/brackets-model"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; import { logger } from "~/utils/logger"; +import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { tournamentMatchPage } from "~/utils/urls"; import { executeRoll } from "../core/executeRoll.server"; import { mapListFromResults, resolveMapList } from "../core/mapList.server"; -import * as PickBan from "../core/PickBan"; -import { tournamentFromDBCached } from "../core/Tournament.server"; import { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; import * as TournamentMatchRepository from "../TournamentMatchRepository.server"; -import { matchPageParamsSchema } from "../tournament-bracket-schemas.server"; -import { - matchEndedEarly, - tournamentTeamToActiveRosterUserIds, -} from "../tournament-bracket-utils"; +import { matchEndedEarly } from "../tournament-match-utils"; -export type TournamentMatchLoaderData = typeof loader; +export type TournamentMatchLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { const { mid: matchId, id: tournamentId } = parseParams({ @@ -51,6 +54,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const results = findResultsByMatchId(matchId); + const reportedWeapons = + await ReportedWeaponRepository.findByTournamentMatchId(matchId); + const matchIsOver = match.opponentOne?.result === "win" || match.opponentTwo?.result === "win"; @@ -148,7 +154,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { match.chatCode && !matchIsOver && match.opponentOne?.id && - match.opponentTwo?.id + match.opponentTwo?.id && + match.status > Status.Locked ) { // only add global chat for active roster (or all if not yet set i.e. first match) // if roster changed mid-set the subs can still see the chat on the match page @@ -180,7 +187,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }); } - const shouldSeeChat = + const hasPermsToSeeChat = tournament.isOrganizerOrStreamer(user) || match.players.some((p) => p.id === user?.id); @@ -195,21 +202,44 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }); const visibleChatCode = - shouldSeeChat && !chatCodeExpired ? match.chatCode : undefined; + hasPermsToSeeChat && !chatCodeExpired ? match.chatCode : undefined; + + const isParticipant = match.players.some((p) => p.id === user?.id); + const canJoin = + !matchIsOver && + match.opponentOne?.id != null && + match.opponentTwo?.id != null && + (isParticipant || tournament.isOrganizerOrStreamer(user)) && + !isLeagueRoundLocked(tournament, match.roundId); + + const [roomLinks, anyUserPrefersNoSplatnet] = canJoin + ? await Promise.all([ + RoomLinkRepository.findByUserIds( + match.players.map((p) => p.id), + 3, + ), + UserRepository.anyUserPrefersNoSplatnet(match.players.map((p) => p.id)), + ]) + : ([[], false] as const); return { - match: shouldSeeChat ? match : { ...match, chatCode: undefined }, + match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined }, results, + reportedWeapons, mapList, matchIsOver, endedEarly, noScreen, chatCode: visibleChatCode, + canJoin, + roomLinks, + anyUserPrefersNoSplatnet, pickBanEventCount: pickBanEvents.length, pickBanEvents: pickBanEvents.map((e) => ({ type: e.type, stageId: e.stageId, mode: e.mode, + createdAt: e.createdAt, })), }; }; diff --git a/app/features/tournament-match/match-page-context.tsx b/app/features/tournament-match/match-page-context.tsx new file mode 100644 index 000000000..dec3b4f0c --- /dev/null +++ b/app/features/tournament-match/match-page-context.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; +import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; +import type { TournamentMatchLoaderData } from "./loaders/to.$id.matches.$mid.server"; +import { matchIsLocked } from "./tournament-match-utils"; + +export type MatchPageTeam = NonNullable>; + +export type MatchTabKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS]; + +type MatchPageMapListEntry = NonNullable< + TournamentMatchLoaderData["mapList"] +>[number]; + +type MatchPageContextValue = { + data: TournamentMatchLoaderData; + teams: [MatchPageTeam | null, MatchPageTeam | null]; + scores: [number, number]; + scoreSum: number; + currentMap: MatchPageMapListEntry | undefined; + tabs: MatchTabKey[]; + teamsMissingActiveRoster: string[]; + turnOfResult: ReturnType; + isPickBanStep: boolean; + matchIsLocked: boolean; +}; + +const MatchPageContext = React.createContext( + null, +); + +export function MatchPageProvider({ + data, + children, +}: { + data: TournamentMatchLoaderData; + children: React.ReactNode; +}) { + const tournament = useTournament(); + const user = useUser(); + + const opponentOneId = data.match.opponentOne?.id; + const opponentTwoId = data.match.opponentTwo?.id; + + const teams: [MatchPageTeam | null, MatchPageTeam | null] = [ + (opponentOneId ? tournament.teamById(opponentOneId) : null) ?? null, + (opponentTwoId ? tournament.teamById(opponentTwoId) : null) ?? null, + ]; + const [teamOne, teamTwo] = teams; + + const scores: [number, number] = [ + data.match.opponentOne?.score ?? 0, + data.match.opponentTwo?.score ?? 0, + ]; + const scoreSum = scores[0] + scores[1]; + + const currentMap = data.mapList?.filter((m) => !m.bannedByTournamentTeamId)[ + scoreSum + ]; + + const teamsMissingActiveRoster = resolveTeamsMissingActiveRoster( + teams, + tournament.minMembersPerTeam, + ); + + const turnOfResult = + teamOne && teamTwo && data.match.roundMaps + ? PickBan.turnOf({ + results: data.results, + maps: data.match.roundMaps, + teams: [ + { id: teamOne.id, seed: teamOne.seed }, + { id: teamTwo.id, seed: teamTwo.seed }, + ], + mapList: data.mapList, + pickBanEventCount: data.pickBanEventCount, + }) + : null; + const isPickBanStep = + turnOfResult !== null && teamsMissingActiveRoster.length === 0; + + const isParticipant = data.match.players.some((p) => p.id === user?.id); + const hasReportedMaps = data.results.length > 0; + + const lockedForCast = matchIsLocked({ + tournament, + matchId: data.match.id, + scores, + }); + + const tabs = resolveVisibleTabs({ + matchIsOver: data.matchIsOver, + canReportScore: tournament.canReportScore({ + matchId: data.match.id, + user, + }), + canReportWeapons: + isParticipant && !tournament.ctx.isFinalized && hasReportedMaps, + canJoin: data.canJoin, + hasCurrentMap: Boolean(currentMap), + hasMissingActiveRoster: teamsMissingActiveRoster.length > 0, + hasReportedMaps, + hasPickBanEvents: data.pickBanEventCount > 0, + isPickBanStep, + isAdminEligible: + tournament.isOrganizerOrStreamer(user) && !tournament.ctx.isFinalized, + leagueRoundLocked: isLeagueRoundLocked(tournament, data.match.roundId), + lockedForCast, + }); + + return ( + + {children} + + ); +} + +export function useMatch() { + const ctx = React.useContext(MatchPageContext); + if (!ctx) { + throw new Error("useMatch must be used within MatchPageProvider"); + } + return ctx; +} + +function resolveVisibleTabs({ + matchIsOver, + canReportScore, + canReportWeapons, + canJoin, + hasCurrentMap, + hasMissingActiveRoster, + hasReportedMaps, + hasPickBanEvents, + isPickBanStep, + isAdminEligible, + leagueRoundLocked, + lockedForCast, +}: { + matchIsOver: boolean; + canReportScore: boolean; + canReportWeapons: boolean; + canJoin: boolean; + hasCurrentMap: boolean; + hasMissingActiveRoster: boolean; + hasReportedMaps: boolean; + hasPickBanEvents: boolean; + isPickBanStep: boolean; + isAdminEligible: boolean; + leagueRoundLocked: boolean; + lockedForCast: boolean; +}): MatchTabKey[] { + const tabs: MatchTabKey[] = []; + + if (matchIsOver) { + tabs.push(TAB_KEYS.RESULT); + } + if (canJoin) { + tabs.push(TAB_KEYS.JOIN); + } + tabs.push(TAB_KEYS.ROSTERS); + if ( + !leagueRoundLocked && + (isPickBanStep || + (canReportScore && + hasCurrentMap && + !hasMissingActiveRoster && + !lockedForCast) || + canReportWeapons) + ) { + tabs.push(TAB_KEYS.ACTION); + } + if (isAdminEligible) { + tabs.push(TAB_KEYS.ADMIN); + } + if (!matchIsOver && (hasReportedMaps || hasPickBanEvents)) { + tabs.push(TAB_KEYS.RESULT); + } + + return tabs; +} + +function resolveTeamsMissingActiveRoster( + teams: [MatchPageTeam | null, MatchPageTeam | null], + minMembersPerTeam: number, +): string[] { + return teams + .filter((team): team is MatchPageTeam => team != null) + .filter( + (team) => !tournamentTeamToActiveRosterUserIds(team, minMembersPerTeam), + ) + .map((team) => team.name); +} diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-match/queries/allMatchResultsByTournamentId.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts rename to app/features/tournament-match/queries/allMatchResultsByTournamentId.server.ts diff --git a/app/features/tournament-bracket/queries/deleteMatchPickBanEvents.server.ts b/app/features/tournament-match/queries/deleteMatchPickBanEvents.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/deleteMatchPickBanEvents.server.ts rename to app/features/tournament-match/queries/deleteMatchPickBanEvents.server.ts diff --git a/app/features/tournament-bracket/queries/deleteParticipantsByMatchGameResultId.server.ts b/app/features/tournament-match/queries/deleteParticipantsByMatchGameResultId.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/deleteParticipantsByMatchGameResultId.server.ts rename to app/features/tournament-match/queries/deleteParticipantsByMatchGameResultId.server.ts diff --git a/app/features/tournament-bracket/queries/deleteTournamentMatchGameResultById.server.ts b/app/features/tournament-match/queries/deleteTournamentMatchGameResultById.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/deleteTournamentMatchGameResultById.server.ts rename to app/features/tournament-match/queries/deleteTournamentMatchGameResultById.server.ts diff --git a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts b/app/features/tournament-match/queries/findResultsByMatchId.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/findResultsByMatchId.server.ts rename to app/features/tournament-match/queries/findResultsByMatchId.server.ts diff --git a/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts b/app/features/tournament-match/queries/insertTournamentMatchGameResult.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts rename to app/features/tournament-match/queries/insertTournamentMatchGameResult.server.ts diff --git a/app/features/tournament-bracket/queries/insertTournamentMatchGameResultParticipant.server.ts b/app/features/tournament-match/queries/insertTournamentMatchGameResultParticipant.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/insertTournamentMatchGameResultParticipant.server.ts rename to app/features/tournament-match/queries/insertTournamentMatchGameResultParticipant.server.ts diff --git a/app/features/tournament-bracket/queries/updateMatchGameResultPoints.server.ts b/app/features/tournament-match/queries/updateMatchGameResultPoints.server.ts similarity index 100% rename from app/features/tournament-bracket/queries/updateMatchGameResultPoints.server.ts rename to app/features/tournament-match/queries/updateMatchGameResultPoints.server.ts diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts similarity index 98% rename from app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts rename to app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 84c8c4721..f2324a934 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -12,6 +12,7 @@ import { dbInsertTournamentTeam, dbStartTournament, } from "~/features/tournament/tournament-test-utils"; +import type { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import type { SerializeFrom } from "~/utils/remix"; import { assertResponseErrored, @@ -21,7 +22,6 @@ import { wrappedLoader, } from "~/utils/Test"; import { action as adminAction } from "../../tournament/routes/to.$id.admin"; -import type { matchSchema } from "../tournament-bracket-schemas.server"; import { action, loader } from "./to.$id.matches.$mid"; const tournamentMatchAction = wrappedAction({ diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx new file mode 100644 index 000000000..8214d3a30 --- /dev/null +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx @@ -0,0 +1,30 @@ +import { useLoaderData } from "react-router"; +import { containerClassName } from "~/components/Main"; +import { MatchPage } from "~/components/match-page/MatchPage"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { action } from "../actions/to.$id.matches.$mid.server"; +import { TournamentMatchBanner } from "../components/TournamentMatchBanner"; +import { TournamentMatchHeader } from "../components/TournamentMatchHeader"; +import { TournamentMatchTabs } from "../components/TournamentMatchTabs"; +import { loader } from "../loaders/to.$id.matches.$mid.server"; +import { MatchPageProvider } from "../match-page-context"; + +export { action, loader }; + +export const handle: SendouRouteHandle = { + i18n: ["q"], +}; + +export default function TournamentMatchPage() { + const data = useLoaderData(); + + return ( + + + + + + + + ); +} diff --git a/app/features/tournament-match/tournament-match-utils.test.ts b/app/features/tournament-match/tournament-match-utils.test.ts new file mode 100644 index 000000000..a80e0d5e4 --- /dev/null +++ b/app/features/tournament-match/tournament-match-utils.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vitest"; +import { + mapCountPlayedInSetWithCertainty, + matchEndedEarly, +} from "./tournament-match-utils"; + +const mapCountParamsToResult: { + bestOf: number; + scores: [number, number]; + expected: number; +}[] = [ + { bestOf: 3, scores: [0, 0], expected: 2 }, + { bestOf: 3, scores: [1, 0], expected: 2 }, + { bestOf: 3, scores: [1, 1], expected: 3 }, + { bestOf: 5, scores: [0, 0], expected: 3 }, + { bestOf: 5, scores: [1, 0], expected: 3 }, + { bestOf: 5, scores: [2, 0], expected: 3 }, + { bestOf: 5, scores: [2, 1], expected: 4 }, + { bestOf: 7, scores: [0, 0], expected: 4 }, + { bestOf: 7, scores: [2, 2], expected: 6 }, +]; + +describe("mapCountPlayedInSetWithCertainty()", () => { + for (const { bestOf, scores, expected } of mapCountParamsToResult) { + test(`bestOf=${bestOf}, scores=${scores.join(",")} -> ${expected}`, () => { + expect(mapCountPlayedInSetWithCertainty({ bestOf, scores })).toBe( + expected, + ); + }); + } +}); + +describe("matchEndedEarly", () => { + test("returns false when no winner", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 1 }, + opponentTwo: { score: 1 }, + count: 3, + countType: "BEST_OF", + }), + ).toBe(false); + }); + + test("returns false when match completed normally (best of 3)", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 2, result: "win" }, + opponentTwo: { score: 1, result: "loss" }, + count: 3, + countType: "BEST_OF", + }), + ).toBe(false); + }); + + test("returns true when match ended early (best of 3)", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 1, result: "win" }, + opponentTwo: { score: 0, result: "loss" }, + count: 3, + countType: "BEST_OF", + }), + ).toBe(true); + }); + + test("returns true when match ended early (best of 5)", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 2, result: "win" }, + opponentTwo: { score: 1, result: "loss" }, + count: 5, + countType: "BEST_OF", + }), + ).toBe(true); + }); + + test("returns false when match completed normally (best of 5)", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 3, result: "win" }, + opponentTwo: { score: 2, result: "loss" }, + count: 5, + countType: "BEST_OF", + }), + ).toBe(false); + }); + + test("returns false when all maps played (play all)", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 2, result: "win" }, + opponentTwo: { score: 1, result: "loss" }, + count: 3, + countType: "PLAY_ALL", + }), + ).toBe(false); + }); + + test("returns true when not all maps played (play all)", () => { + expect( + matchEndedEarly({ + opponentOne: { score: 2, result: "win" }, + opponentTwo: { score: 0, result: "loss" }, + count: 3, + countType: "PLAY_ALL", + }), + ).toBe(true); + }); + + test("handles missing scores as 0", () => { + expect( + matchEndedEarly({ + opponentOne: { result: "win" }, + opponentTwo: { result: "loss" }, + count: 3, + countType: "BEST_OF", + }), + ).toBe(true); + }); +}); diff --git a/app/features/tournament-match/tournament-match-utils.ts b/app/features/tournament-match/tournament-match-utils.ts new file mode 100644 index 000000000..7a9e9df17 --- /dev/null +++ b/app/features/tournament-match/tournament-match-utils.ts @@ -0,0 +1,150 @@ +import type { TFunction } from "i18next"; +import * as R from "remeda"; +import type { TournamentRoundMaps } from "~/db/tables"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types"; +import { logger } from "~/utils/logger"; + +export const tournamentMatchWebsocketRoom = (matchId: number) => + `match__${matchId}`; + +export function resolveHostingTeam( + teams: [TournamentDataTeam, TournamentDataTeam], +) { + if (teams[0].prefersNotToHost && !teams[1].prefersNotToHost) return teams[1]; + if (!teams[0].prefersNotToHost && teams[1].prefersNotToHost) return teams[0]; + if (!teams[0].seed && !teams[1].seed) return teams[0]; + if (!teams[0].seed) return teams[1]; + if (!teams[1].seed) return teams[0]; + if (teams[0].seed < teams[1].seed) return teams[0]; + if (teams[1].seed < teams[0].seed) return teams[1]; + + logger.error("resolveHostingTeam: unexpected default"); + return teams[0]; +} + +export function mapCountPlayedInSetWithCertainty({ + bestOf, + scores, +}: { + bestOf: number; + scores: [number, number]; +}) { + const maxScore = Math.max(...scores); + const scoreSum = R.sum(scores); + + return scoreSum + (Math.ceil(bestOf / 2) - maxScore); +} + +export function matchIsLocked({ + tournament, + matchId, + scores, +}: { + tournament: Tournament; + matchId: number; + scores: [number, number]; +}) { + if (scores[0] !== 0 || scores[1] !== 0) return false; + + const locked = tournament.ctx.castedMatchesInfo?.lockedMatches ?? []; + + return locked.some((lm) => lm.matchId === matchId); +} + +export function pickInfoText({ + map, + t, + teams, +}: { + map?: { stageId: StageId; mode: ModeShort; source: TournamentMaplistSource }; + t: TFunction<["tournament"]>; + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + if (!map) return ""; + + if (map.source === teams[0].id) { + return t("tournament:pickInfo.team", { number: 1 }); + } + if (map.source === teams[1].id) { + return t("tournament:pickInfo.team", { number: 2 }); + } + if (map.source === "TIEBREAKER") { + return t("tournament:pickInfo.tiebreaker"); + } + if (map.source === "BOTH") return t("tournament:pickInfo.both"); + if (map.source === "DEFAULT") return t("tournament:pickInfo.default"); + if (map.source === "COUNTERPICK") { + return t("tournament:pickInfo.counterpick"); + } + if (map.source === "ROLL") { + return t("tournament:pickInfo.roll"); + } + if (map.source === "TO") return ""; + + logger.error(`Unknown source: ${String(map.source)}`); + return ""; +} + +export function isSetOverByResults({ + results, + count, + countType, +}: { + results: Array<{ winnerTeamId: number }>; + count: number; + countType: TournamentRoundMaps["type"]; +}) { + const winCounts = R.countBy(results, (r) => r.winnerTeamId); + + if (countType === "PLAY_ALL") { + return R.sum(Object.values(winCounts)) === count; + } + + const maxWins = Math.max(...Object.values(winCounts)); + + // best of + return maxWins >= Math.ceil(count / 2); +} + +export function isSetOverByScore({ + scores, + count, + countType, +}: { + scores: [number, number]; + count: number; + countType: TournamentRoundMaps["type"]; +}) { + if (countType === "PLAY_ALL") { + return R.sum(scores) === count; + } + + const matchOverAtXWins = Math.ceil(count / 2); + return scores[0] === matchOverAtXWins || scores[1] === matchOverAtXWins; +} + +export function matchEndedEarly({ + opponentOne, + opponentTwo, + count, + countType, +}: { + opponentOne: { score?: number; result?: "win" | "loss" }; + opponentTwo: { score?: number; result?: "win" | "loss" }; + count: number; + countType: TournamentRoundMaps["type"]; +}) { + if (opponentOne.result !== "win" && opponentTwo.result !== "win") { + return false; + } + + const scores: [number, number] = [ + opponentOne.score ?? 0, + opponentTwo.score ?? 0, + ]; + + return !isSetOverByScore({ scores, count, countType }); +} diff --git a/app/features/tournament-organization/components/BannedPlayersList.tsx b/app/features/tournament-organization/components/BannedPlayersList.tsx index 327d61d49..a50980671 100644 --- a/app/features/tournament-organization/components/BannedPlayersList.tsx +++ b/app/features/tournament-organization/components/BannedPlayersList.tsx @@ -82,7 +82,7 @@ export function BannedUsersList({ {formatDate(databaseTimestampToDate(bannedUser.updatedAt), { day: "numeric", - month: "short", + month: "numeric", year: "numeric", })} @@ -92,7 +92,7 @@ export function BannedUsersList({ databaseTimestampToDate(bannedUser.expiresAt), { day: "numeric", - month: "short", + month: "numeric", year: "numeric", }, ) diff --git a/app/features/tournament-organization/components/EventCalendar.tsx b/app/features/tournament-organization/components/EventCalendar.tsx index e6d5a9d63..430476340 100644 --- a/app/features/tournament-organization/components/EventCalendar.tsx +++ b/app/features/tournament-organization/components/EventCalendar.tsx @@ -132,7 +132,7 @@ function MonthSelector({ month, year }: { month: number; year: number }) {
{formatDate(date, { year: "numeric", - month: "long", + month: "numeric", })}
{t("org:events.established.short")}{" "} {formatDate(databaseTimestampToDate(series.established), { - month: "long", + month: "numeric", year: "numeric", })}
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 5b76db9cb..f713b3084 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -490,7 +490,9 @@ export function forShowcase() { eb("CalendarEventDate.startTime", ">", databaseTimestampNow()), ]), ) - .select(({ fn }) => [fn.countAll().as("teamsCount")]) + .select(({ fn }) => [ + fn.count("TournamentTeam.id").distinct().as("teamsCount"), + ]) .as("teamsCount"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonObjectFrom( @@ -945,6 +947,7 @@ export function pickBanEventsByMatchId(matchId: number) { "TournamentMatchPickBanEvent.stageId", "TournamentMatchPickBanEvent.type", "TournamentMatchPickBanEvent.number", + "TournamentMatchPickBanEvent.createdAt", ]) .where("matchId", "=", matchId) .orderBy("TournamentMatchPickBanEvent.number", "asc") diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 0661e20e4..64458bc1c 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -423,11 +423,17 @@ export function checkIn( return db.transaction().execute(async (trx) => { let query = trx .deleteFrom("TournamentTeamCheckIn") - .where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId) - .where("TournamentTeamCheckIn.isCheckOut", "=", 1); + .where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId); if (typeof bracketIdx === "number") { query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx); + } else { + query = query.where((eb) => + eb.or([ + eb("TournamentTeamCheckIn.isCheckOut", "=", 1), + eb("TournamentTeamCheckIn.bracketIdx", "is", null), + ]), + ); } await query.execute(); diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 729e4ca66..2f8e0f406 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -13,11 +13,9 @@ import { clearTournamentDataCache, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; -import { - tournamentMatchWebsocketRoom, - tournamentWebsocketRoom, -} from "~/features/tournament-bracket/tournament-bracket-utils"; +import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; +import { tournamentMatchWebsocketRoom } from "~/features/tournament-match/tournament-match-utils"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; diff --git a/app/features/tournament/core/sets.server.ts b/app/features/tournament/core/sets.server.ts index 503febe70..8be1f9e1c 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -1,14 +1,18 @@ import type { Tables } from "~/db/tables"; +import type { FindByTournamentTeamIdItem } from "~/features/tournament-match/TournamentMatchRepository.server"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { sourceTypes } from "~/modules/tournament-map-list-generator/constants"; import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; -import { findRoundsByTournamentId } from "../queries/findRoundsByTournamentId.server"; -import { - type SetHistoryByTeamIdItem, - setHistoryByTeamId, -} from "../queries/setHistoryByTeamId.server"; + +export interface AllRoundsItem { + stageId: number; + stageName: string; + stageType: Tables["TournamentStage"]["type"]; + roundNumber: number; + groupNumber: number; +} export interface PlayedSet { tournamentMatchId: number; @@ -78,15 +82,12 @@ export function winCounts(sets: PlayedSet[]) { } export function tournamentTeamSets({ - tournamentTeamId, - tournamentId, + sets, + allRounds, }: { - tournamentTeamId: number; - tournamentId: number; + sets: FindByTournamentTeamIdItem[]; + allRounds: AllRoundsItem[]; }): PlayedSet[] { - const sets = setHistoryByTeamId(tournamentTeamId); - const allRounds = findRoundsByTournamentId(tournamentId); - return sets.map((set) => { const round = allRounds.find((round) => round.stageId === set.stageId) ?? allRounds[0]; @@ -157,7 +158,7 @@ function parseTournamentMaplistSource(source: string): TournamentMaplistSource { return parsed; } -function flipScoreIfNeeded(set: SetHistoryByTeamIdItem): [number, number] { +function flipScoreIfNeeded(set: FindByTournamentTeamIdItem): [number, number] { const score: [number, number] = [ set.opponentOneScore ?? 0, set.opponentTwoScore ?? 0, diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts index 1ab1f5fa5..0614df7bd 100644 --- a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts @@ -1,8 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; +import invariant from "~/utils/invariant"; import { parseParams } from "~/utils/remix.server"; -import { tournamentTeamSets, winCounts } from "../core/sets.server"; +import { + type AllRoundsItem, + tournamentTeamSets, + winCounts, +} from "../core/sets.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { const { id: tournamentId, tid: tournamentTeamId } = parseParams({ @@ -15,7 +21,23 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { throw new Response(null, { status: 404 }); } - const sets = tournamentTeamSets({ tournamentTeamId, tournamentId }); + const setHistory = + await TournamentMatchRepository.findByTournamentTeamId(tournamentTeamId); + const allRounds: AllRoundsItem[] = tournament.data.round.map((round) => { + const stage = tournament.data.stage.find((s) => s.id === round.stage_id); + const group = tournament.data.group.find((g) => g.id === round.group_id); + invariant(stage && group, "Stage or group not found for round"); + + return { + stageId: stage.id, + stageName: stage.name, + stageType: stage.type, + roundNumber: round.number, + groupNumber: group.number, + }; + }); + + const sets = tournamentTeamSets({ sets: setHistory, allRounds }); return { tournamentTeamId, diff --git a/app/features/tournament/queries/findRoundsByTournamentId.server.ts b/app/features/tournament/queries/findRoundsByTournamentId.server.ts deleted file mode 100644 index bea1efd54..000000000 --- a/app/features/tournament/queries/findRoundsByTournamentId.server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; - -const stm = sql.prepare(/* sql */ ` - select - "TournamentStage"."id" as "stageId", - "TournamentStage"."name" as "stageName", - "TournamentStage"."type" as "stageType", - "TournamentRound"."number" as "roundNumber", - "TournamentGroup"."number" as "groupNumber" - from "TournamentStage" - left join "TournamentGroup" on "TournamentGroup"."stageId" = "TournamentStage"."id" - left join "TournamentRound" on "TournamentRound"."groupId" = "TournamentGroup"."id" - where "TournamentStage"."tournamentId" = @tournamentId - group by "TournamentStage"."id", "TournamentRound"."number", "TournamentGroup"."number" -`); - -export function findRoundsByTournamentId(tournamentId: number) { - return stm.all({ tournamentId }) as Array<{ - stageId: number; - stageName: string; - stageType: Tables["TournamentStage"]["type"]; - roundNumber: number; - groupNumber: number; - }>; -} diff --git a/app/features/tournament/queries/setHistoryByTeamId.server.ts b/app/features/tournament/queries/setHistoryByTeamId.server.ts deleted file mode 100644 index cfe88b11f..000000000 --- a/app/features/tournament/queries/setHistoryByTeamId.server.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as R from "remeda"; -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { parseDBArray } from "~/utils/sql"; - -const stm = sql.prepare(/* sql */ ` - with "q1" as ( - select - "m"."id" as "tournamentMatchId", - "m"."opponentOne" ->> '$.score' as "opponentOneScore", - "m"."opponentTwo" ->> '$.score' as "opponentTwoScore", - "otherTeam"."name" as "otherTeamName", - "otherTeam"."id" as "otherTeamId", - "round"."number" as "roundNumber", - "round"."stageId" as "stageId", - "group"."number" as "groupNumber", - json_group_array( - json_object( - 'mode', - "r"."mode", - 'stageId', - "r"."stageId", - 'wasWinner', - "r"."winnerTeamId" == @tournamentTeamId, - 'source', - "r"."source" - ) - ) as "matches" - from "TournamentMatch" as "m" - left join "TournamentMatchGameResult" as "r" on "m"."id" = "r"."matchId" - left join "TournamentRound" as "round" on "m"."roundId" = "round"."id" - left join "TournamentGroup" as "group" on "m"."groupId" = "group"."id" - left join "TournamentTeam" as "otherTeam" on - ( - "m"."opponentOne" ->> '$.id' != @tournamentTeamId - and - "m"."opponentOne" ->> '$.id' = "otherTeam"."id" - ) or - ( - "m"."opponentTwo" ->> '$.id' != @tournamentTeamId - and - "m"."opponentTwo" ->> '$.id' = "otherTeam"."id" - ) - where - ( - "m"."opponentOne" ->> '$.id' = @tournamentTeamId - or - "m"."opponentTwo" ->> '$.id' = @tournamentTeamId - ) - and "m"."status" >= 4 - group by "m"."id" - order by "groupNumber" asc, "roundNumber" asc, "r"."number" asc - ) - select - "q1".*, - json_group_array( - json_object( - 'id', - "u"."id", - 'username', - "u"."username", - 'discordAvatar', - "u"."discordAvatar", - 'discordId', - "u"."discordId", - 'customUrl', - "u"."customUrl" - ) - ) as "players" - from "q1" - left join "TournamentMatchGameResult" as "r" on "q1"."tournamentMatchId" = "r"."matchId" - left join "TournamentMatchGameResultParticipant" as "p" on "r"."id" = "p"."matchGameResultId" - left join "User" as "u" on "p"."userId" = "u"."id" - -- filters out own team results - inner join "TournamentTeamMember" as "m" on "p"."userId" = "m"."userId" - and "m"."tournamentTeamId" == "q1"."otherTeamId" - group by "q1"."tournamentMatchId" -`); - -export interface SetHistoryByTeamIdItem { - tournamentMatchId: number; - opponentOneScore: number | null; - opponentTwoScore: number | null; - otherTeamName: string; - otherTeamId: number; - roundNumber: number; - stageId: number; - groupNumber: number; - matches: { - stageId: StageId; - source: Tables["TournamentMatchGameResult"]["source"]; - mode: ModeShort; - wasWinner: number; - }[]; - players: Array< - Pick< - Tables["User"], - "id" | "username" | "discordAvatar" | "discordId" | "customUrl" - > - >; -} - -export function setHistoryByTeamId( - tournamentTeamId: number, -): Array { - const rows = stm.all({ tournamentTeamId }) as any[]; - - return rows.map((row) => { - return { - ...row, - matches: parseDBArray(row.matches), - // TODO: there is probably a way to do this in SQL - players: R.uniqueBy(parseDBArray(row.players), (u) => u.id), - }; - }); -} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index a6ab4b8a6..297d015c8 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -589,6 +589,10 @@ function DownloadParticipants() { } function simpleListInSeededOrder() { + const hasCheckedInTeams = tournament.ctx.teams.some( + (team) => team.checkIns.length > 0, + ); + return tournament.ctx.teams .slice() .sort( @@ -596,7 +600,7 @@ function DownloadParticipants() { (a.seed ?? Number.POSITIVE_INFINITY) - (b.seed ?? Number.POSITIVE_INFINITY), ) - .filter((team) => team.checkIns.length > 0) + .filter((team) => !hasCheckedInTeams || team.checkIns.length > 0) .map((team) => team.name) .join("\n"); } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 11d44cbb9..629a0c723 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -129,7 +129,12 @@ export default function TournamentRegisterPage() { minute: "numeric", hour: "numeric", day: "numeric", - month: "long", + month: "numeric", + year: + tournament.ctx.startTime.getFullYear() !== + new Date().getFullYear() + ? "2-digit" + : undefined, }} /> ) : null} @@ -483,13 +488,6 @@ function RegistrationProgress({ canCheckIn={ steps.filter((step) => step.status === "incomplete").length === 1 } - status={ - tournament.regularCheckInIsOpen - ? "OPEN" - : tournament.regularCheckInHasEnded - ? "OVER" - : "UPCOMING" - } startDate={tournament.regularCheckInStartsAt} endDate={tournament.regularCheckInEndsAt} checkedIn={checkedIn} @@ -510,13 +508,11 @@ function RegistrationProgress({ } function CheckIn({ - status, canCheckIn, startDate, endDate, checkedIn, }: { - status: "OVER" | "OPEN" | "UPCOMING"; canCheckIn: boolean; startDate: Date; endDate: Date; @@ -525,12 +521,14 @@ function CheckIn({ const { t } = useTranslation(["tournament"]); const isHydrated = useHydrated(); const fetcher = useFetcher(); - const { formatTime } = useTimeFormat(); + const { formatDate } = useTimeFormat(); - useAutoRerender(); + const now = useAutoRerender(); + const status: "OVER" | "OPEN" | "UPCOMING" = + now > endDate ? "OVER" : now >= startDate ? "OPEN" : "UPCOMING"; const checkInStartsString = isHydrated - ? formatTime(startDate, { + ? formatDate(startDate, { minute: "numeric", hour: "numeric", day: "2-digit", @@ -539,7 +537,7 @@ function CheckIn({ : ""; const checkInEndsString = isHydrated - ? formatTime(endDate, { + ? formatDate(endDate, { minute: "numeric", hour: "numeric", day: "2-digit", diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 23cb3d281..684501d96 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -29,7 +29,7 @@ import { useTournament } from "./to.$id"; export { loader }; export const meta: MetaFunction = (args) => { - const tournamentData = (args.matches[1].data as any) + const tournamentData = JSON.parse(args.matches[1].data as any) ?.tournament as TournamentData; if (!args.data || !tournamentData) return []; diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 349942302..2629f0411 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -96,9 +96,11 @@ export function endDroppedTeamMatches({ { id: match.id, opponent1: { + score: match.opponent1.score, result: winnerTeamId === match.opponent1.id ? "win" : "loss", }, opponent2: { + score: match.opponent2.score, result: winnerTeamId === match.opponent2.id ? "win" : "loss", }, }, diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 60b8284ab..913134ce7 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -288,6 +288,8 @@ export async function upsertWidgets( return db.transaction().execute(async (trx) => { await trx.deleteFrom("UserWidget").where("userId", "=", userId).execute(); + if (widgets.length === 0) return; + await trx .insertInto("UserWidget") .values( @@ -1275,6 +1277,21 @@ export async function anyUserPrefersNoScreen( return Boolean(result); } +export async function anyUserPrefersNoSplatnet( + userIds: number[], +): Promise { + if (userIds.length === 0) return false; + + const result = await db + .selectFrom("User") + .select("User.noSplatnet") + .where("User.id", "in", userIds) + .where("User.noSplatnet", "=", 1) + .executeTakeFirst(); + + return Boolean(result); +} + export async function socialLinksByUserId(userId: number) { const user = await db .selectFrom("User") diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index db7e889b4..bf321e767 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -45,8 +45,8 @@ export function UserResultsTable({ {hasHighlightCheckboxes && } {t("results.placing")} - {t("results.date")} {t("results.tournament")} + {t("results.date")} {t("results.participation")} {t("results.team")} @@ -86,14 +86,7 @@ export function UserResultsTable({
- - {formatDate(databaseTimestampToDate(result.startTime), { - day: "numeric", - month: "short", - year: "numeric", - })} - - +
{result.eventId ? ( @@ -106,11 +99,12 @@ export function UserResultsTable({ ) : null} + {result.tier ? : null} {result.eventName} - {result.tier ? : null} {result.div ? ( ({result.div}) ) : null} @@ -127,10 +120,17 @@ export function UserResultsTable({ ) : null}
+ + {formatDate(databaseTimestampToDate(result.startTime), { + day: "numeric", + month: "numeric", + year: "2-digit", + })} + - +
@@ -417,7 +417,7 @@ function HighlightedResults({
{formatDate(databaseTimestampToDate(result.startTime), { day: "numeric", - month: "short", + month: "numeric", year: "numeric", })}
@@ -538,6 +538,7 @@ function XRankPeaks({ } function TimezoneWidget({ timezone }: { timezone: string }) { + const { formatTime, formatDate } = useTimeFormat(); const [currentTime, setCurrentTime] = React.useState(() => new Date()); React.useEffect(() => { @@ -549,28 +550,23 @@ function TimezoneWidget({ timezone }: { timezone: string }) { }, []); try { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - - const dateFormatter = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - weekday: "short", - day: "numeric", - month: "short", - }); - return (
- {formatter.format(currentTime)} + {formatTime(currentTime, { + timeZone: timezone, + hour: "numeric", + minute: "2-digit", + second: "2-digit", + })}
- {dateFormatter.format(currentTime)} + {formatDate(currentTime, { + timeZone: timezone, + weekday: "short", + day: "numeric", + month: "numeric", + })}
); diff --git a/app/features/user-page/loaders/u.$identifier.results.server.ts b/app/features/user-page/loaders/u.$identifier.results.server.ts index 5be3ea471..bfc8c0e8c 100644 --- a/app/features/user-page/loaders/u.$identifier.results.server.ts +++ b/app/features/user-page/loaders/u.$identifier.results.server.ts @@ -1,9 +1,12 @@ import type { LoaderFunctionArgs } from "react-router"; -import { redirect } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { SerializeFrom } from "~/utils/remix"; -import { notFoundIfFalsy, parseSafeSearchParams } from "~/utils/remix.server"; +import { + notFoundIfFalsy, + parseSafeSearchParams, + redirectIfPageOutOfBounds, +} from "~/utils/remix.server"; import { HIGHLIGHTS_RESULTS_MAX, RESULTS_PER_PAGE, @@ -57,33 +60,16 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { }), ]); - const maxPage = Math.ceil(totalCount / RESULTS_PER_PAGE); + const pagesCount = Math.ceil(totalCount / RESULTS_PER_PAGE); - redirectIfPageOutOfBounds({ request, page, maxPage }); + redirectIfPageOutOfBounds({ request, page, pagesCount }); return { results: { value: results, currentPage: page, - pages: maxPage, + pages: pagesCount, }, hasHighlightedResults, }; }; - -function redirectIfPageOutOfBounds({ - request, - page, - maxPage, -}: { - request: Request; - page: number; - maxPage: number; -}) { - if (page <= maxPage || page === 1) return; - - const url = new URL(request.url); - const searchParams = new URLSearchParams(url.searchParams); - searchParams.set("page", String(Math.max(maxPage, 1))); - throw redirect(`${url.pathname}?${searchParams.toString()}`); -} diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts index bf92700b1..30f5af0d4 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts @@ -4,10 +4,10 @@ import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepos import * as SkillRepository from "~/features/mmr/SkillRepository.server"; import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server"; -import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/seasonReportedWeaponsByUserId.server"; import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server"; import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server"; import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server"; +import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { SerializeFrom } from "~/utils/remix"; @@ -91,7 +91,10 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { : null, weapons: info === "weapons" - ? seasonReportedWeaponsByUserId({ season, userId: user.id }) + ? await ReportedWeaponRepository.seasonReportedWeaponsByUserId({ + season, + userId: user.id, + }) : null, players: info === "enemies" || info === "mates" diff --git a/app/features/user-page/loaders/u.$identifier.vods.server.ts b/app/features/user-page/loaders/u.$identifier.vods.server.ts index b0da1b7fe..64a9bcbb7 100644 --- a/app/features/user-page/loaders/u.$identifier.vods.server.ts +++ b/app/features/user-page/loaders/u.$identifier.vods.server.ts @@ -1,15 +1,40 @@ import type { LoaderFunctionArgs } from "react-router"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import * as VodRepository from "~/features/vods/VodRepository.server"; -import { notFoundIfFalsy } from "~/utils/remix.server"; +import { VODS_PAGE_BATCH_SIZE } from "~/features/vods/vods-constants"; +import { userVodsSearchParamsSchema } from "~/features/vods/vods-schemas"; +import { + notFoundIfFalsy, + parseSearchParams, + redirectIfPageOutOfBounds, +} from "~/utils/remix.server"; -export const loader = async ({ params }: LoaderFunctionArgs) => { +export const loader = async ({ params, request }: LoaderFunctionArgs) => { const userId = notFoundIfFalsy( await UserRepository.identifierToUserId(params.identifier!), ).id; + const { page } = parseSearchParams({ + request, + schema: userVodsSearchParamsSchema, + }); + + const [vods, totalCount] = await Promise.all([ + VodRepository.findVods({ + userId, + limit: VODS_PAGE_BATCH_SIZE, + offset: (page - 1) * VODS_PAGE_BATCH_SIZE, + }), + VodRepository.countVods({ userId }), + ]); + + const pagesCount = Math.max(1, Math.ceil(totalCount / VODS_PAGE_BATCH_SIZE)); + + redirectIfPageOutOfBounds({ request, page, pagesCount }); + return { - // TODO: add pagination instead of not showing oldest vods at all - vods: await VodRepository.findByUserId(userId), + vods, + currentPage: page, + pagesCount, }; }; diff --git a/app/features/user-page/routes/u.$identifier.admin.tsx b/app/features/user-page/routes/u.$identifier.admin.tsx index 33f826bd8..b8b713ff9 100644 --- a/app/features/user-page/routes/u.$identifier.admin.tsx +++ b/app/features/user-page/routes/u.$identifier.admin.tsx @@ -66,7 +66,7 @@ function AccountInfos() { {data.createdAt ? formatDateTime(databaseTimestampToDate(data.createdAt), { year: "numeric", - month: "long", + month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", @@ -78,7 +78,7 @@ function AccountInfos() {
{formatDateTime(new Date(data.discordAccountCreatedAt), { year: "numeric", - month: "long", + month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", @@ -131,7 +131,7 @@ function ModNotes() {

{formatDateTime(databaseTimestampToDate(note.createdAt), { year: "numeric", - month: "long", + month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", @@ -197,7 +197,7 @@ function BanLog() {

{formatDateTime(databaseTimestampToDate(ban.createdAt), { year: "numeric", - month: "long", + month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", @@ -215,7 +215,7 @@ function BanLog() { {ban.banned !== 1 ? formatDateTime(databaseTimestampToDate(ban.banned), { year: "numeric", - month: "long", + month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", @@ -254,7 +254,7 @@ function FriendCodes() { {index === 0 ? "Current" : "Past"} - Added on{" "} {formatDateTime(databaseTimestampToDate(fc.createdAt), { year: "numeric", - month: "long", + month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index fe0baafcf..79ecce7c3 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -71,13 +71,13 @@ export default function UserResultsPage() { user={layoutData.user} backTo={userPage(layoutData.user)} /> -

+

{showAll || !data.hasHighlightedResults ? t("results.title") : t("results.highlights")}

-
+
{user ? ( {formatDate(new Date(starts), { day: "numeric", - month: "long", + month: "numeric", year: isDifferentYears ? "numeric" : undefined, })}{" "} -{" "} {formatDate(new Date(ends), { day: "numeric", - month: "long", + month: "numeric", year: "numeric", })} @@ -777,7 +777,7 @@ function Results({ {isHydrated ? formatDate(databaseTimestampToDate(result.createdAt), { weekday: "long", - month: "long", + month: "numeric", day: "numeric", }) : "t"} @@ -886,7 +886,7 @@ function TournamentResult({ result }: { result: SeasonTournamentResult }) { [styles.seasonMatchWithSubSection]: result.spDiff, })} > -
+
(); const layoutData = parentRoute.data as UserPageLoaderData; + const [, setSearchParams] = useSearchParams(); + + const setPage = (page: number) => { + setSearchParams((params) => { + params.set("page", String(page)); + return params; + }); + }; return (
@@ -31,6 +40,15 @@ export default function UserVodsPage() { ))}
+ {data.pagesCount > 1 ? ( + setPage(data.currentPage + 1)} + previousPage={() => setPage(data.currentPage - 1)} + setPage={setPage} + /> + ) : null}
); } diff --git a/app/features/user-page/user-page.module.css b/app/features/user-page/user-page.module.css index 88a6b57c8..2da668030 100644 --- a/app/features/user-page/user-page.module.css +++ b/app/features/user-page/user-page.module.css @@ -126,11 +126,40 @@ overflow-x: auto; } +.resultsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-3); +} + +.resultsHeaderActions { + display: flex; + align-items: center; + gap: var(--s-2); +} + .resultsFilterInput { width: 12rem; max-width: 100%; } +@media screen and (max-width: 599px) { + .resultsHeader { + flex-direction: column; + align-items: stretch; + } + + .resultsHeaderActions { + justify-content: space-between; + } + + .resultsFilterInput { + flex: 1; + width: auto; + } +} + .resultsTableHighlights { border: var(--s-2) solid var(--color-bg-high); padding-inline: 0 !important; @@ -314,6 +343,8 @@ display: flex; gap: var(--s-2); flex-direction: row; + flex-wrap: wrap; + justify-content: center; margin: 0 auto; padding: 0; @@ -398,7 +429,6 @@ bottom: 0; background-color: var(--color-bg); padding: var(--s-2) 0; - border-top: 1px solid var(--color-bg-high); z-index: 10; display: flex; flex-direction: column; diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index ab3aa3373..b565d3098 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -33,6 +33,7 @@ export async function findVods({ type, userId, limit = VODS_PAGE_BATCH_SIZE, + offset = 0, }: { weapon?: MainWeaponId; mode?: ModeShort; @@ -40,6 +41,7 @@ export async function findVods({ type?: Tables["Video"]["type"]; userId?: number; limit?: number; + offset?: number; }) { let query = db .selectFrom("Video") @@ -49,7 +51,6 @@ export async function findVods({ "VideoMatch.id", "VideoMatchPlayer.videoMatchId", ) - .leftJoin("User", "VideoMatchPlayer.playerUserId", "User.id") .selectAll("Video") .select(({ fn, ref, eb }) => [ sql< @@ -74,7 +75,7 @@ export async function findVods({ ).as("players"), ]); if (userId) { - query = query.where("User.id", "=", userId); + query = query.where("VideoMatchPlayer.playerUserId", "=", userId); } else { if (type) { query = query.where("Video.type", "=", type); @@ -90,14 +91,14 @@ export async function findVods({ query = query.where( "VideoMatchPlayer.weaponSplId", "in", - // TODO: temporary fix until we have a proper search params parsing in place - weaponIdToArrayWithAlts(Number(weapon) as MainWeaponId), + weaponIdToArrayWithAlts(weapon), ); } const result = await query .groupBy("Video.id") .orderBy("Video.youtubeDate", "desc") .limit(limit) + .offset(offset) .execute(); const vods = result.map((value) => { @@ -110,6 +111,53 @@ export async function findVods({ return vods; } +export async function countVods({ + weapon, + mode, + stageId, + type, + userId, +}: { + weapon?: MainWeaponId; + mode?: ModeShort; + stageId?: StageId; + type?: Tables["Video"]["type"]; + userId?: number; +}) { + let query = db + .selectFrom("Video") + .leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id") + .leftJoin( + "VideoMatchPlayer", + "VideoMatch.id", + "VideoMatchPlayer.videoMatchId", + ) + .select(({ fn }) => fn.count("Video.id").distinct().as("count")); + if (userId) { + query = query.where("VideoMatchPlayer.playerUserId", "=", userId); + } else { + if (type) { + query = query.where("Video.type", "=", type); + } + if (mode) { + query = query.where("VideoMatch.mode", "=", mode); + } + if (stageId) { + query = query.where("VideoMatch.stageId", "=", stageId); + } + } + if (weapon) { + query = query.where( + "VideoMatchPlayer.weaponSplId", + "in", + weaponIdToArrayWithAlts(weapon), + ); + } + + const result = await query.executeTakeFirstOrThrow(); + return result.count; +} + export async function findVodById(id: Tables["Video"]["id"]) { const videoQuery = db .selectFrom("Video") diff --git a/app/features/vods/loaders/vods.server.ts b/app/features/vods/loaders/vods.server.ts index b8f69fd07..6d5916231 100644 --- a/app/features/vods/loaders/vods.server.ts +++ b/app/features/vods/loaders/vods.server.ts @@ -1,28 +1,34 @@ import type { LoaderFunctionArgs } from "react-router"; +import { + parseSearchParams, + redirectIfPageOutOfBounds, +} from "~/utils/remix.server"; import * as VodRepository from "../VodRepository.server"; import { VODS_PAGE_BATCH_SIZE } from "../vods-constants"; +import { vodsSearchParamsSchema } from "../vods-schemas"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - - const limit = Number(url.searchParams.get("limit") ?? VODS_PAGE_BATCH_SIZE); - - const vods = await VodRepository.findVods({ - ...Object.fromEntries( - Array.from(url.searchParams.entries()).filter(([, value]) => value), - ), - limit: limit + 1, + const { page, ...filters } = parseSearchParams({ + request, + schema: vodsSearchParamsSchema, }); - let hasMoreVods = false; - if (vods.length > limit) { - vods.pop(); - hasMoreVods = true; - } + const [vods, totalCount] = await Promise.all([ + VodRepository.findVods({ + ...filters, + limit: VODS_PAGE_BATCH_SIZE, + offset: (page - 1) * VODS_PAGE_BATCH_SIZE, + }), + VodRepository.countVods(filters), + ]); + + const pagesCount = Math.max(1, Math.ceil(totalCount / VODS_PAGE_BATCH_SIZE)); + + redirectIfPageOutOfBounds({ request, page, pagesCount }); return { vods, - limit, - hasMoreVods, + currentPage: page, + pagesCount, }; }; diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index 18a41e081..4a6757f7a 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -8,12 +8,12 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { WeaponSelect } from "~/components/WeaponSelect"; import { YouTubeEmbed } from "~/components/YouTubeEmbed"; -import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks"; import type { ArrayItemRenderContext, CustomFieldRenderProps } from "~/form"; import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; import type { FormRenderProps } from "~/form/SendouForm"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; +import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import { useHasRole } from "~/modules/permissions/hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; diff --git a/app/features/vods/routes/vods.tsx b/app/features/vods/routes/vods.tsx index 1fd364917..8744204a0 100644 --- a/app/features/vods/routes/vods.tsx +++ b/app/features/vods/routes/vods.tsx @@ -1,9 +1,9 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData, useSearchParams } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; +import { Pagination } from "~/components/Pagination"; import { WeaponSelect } from "~/components/WeaponSelect"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -13,7 +13,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl, VODS_PAGE } from "~/utils/urls"; import { VodListing } from "../components/VodListing"; import { loader } from "../loaders/vods.server"; -import { VODS_PAGE_BATCH_SIZE, videoMatchTypes } from "../vods-constants"; +import { videoMatchTypes } from "../vods-constants"; import styles from "./vods.module.css"; export { loader }; @@ -38,15 +38,23 @@ export const meta: MetaFunction = (args) => { }; export default function VodsSearchPage() { - const { t } = useTranslation(["vods", "common"]); + const { t } = useTranslation(["vods"]); const data = useLoaderData(); const [, setSearchParams] = useSearchParams(); const addToSearchParams = (key: string, value: string | number) => { - setSearchParams((params) => ({ - ...Object.fromEntries(params.entries()), - [key]: String(value), - })); + setSearchParams((params) => { + params.set(key, String(value)); + params.delete("page"); + return params; + }); + }; + + const setPage = (page: number) => { + setSearchParams((params) => { + params.set("page", String(page)); + return params; + }); }; return ( @@ -59,17 +67,15 @@ export default function VodsSearchPage() { ))}
- {data.hasMoreVods && ( - - addToSearchParams("limit", data.limit + VODS_PAGE_BATCH_SIZE) - } - > - {t("common:actions.loadMore")} - - )} + {data.pagesCount > 1 ? ( + setPage(data.currentPage + 1)} + previousPage={() => setPage(data.currentPage - 1)} + setPage={setPage} + /> + ) : null} ) : (
{t("vods:noVods")}
diff --git a/app/features/vods/vods-schemas.ts b/app/features/vods/vods-schemas.ts index 954c009c9..cd79e2919 100644 --- a/app/features/vods/vods-schemas.ts +++ b/app/features/vods/vods-schemas.ts @@ -29,6 +29,18 @@ import { extractYoutubeIdFromVideoUrl } from "./vods-utils"; export const HOURS_MINUTES_SECONDS_REGEX = /^(\d{1,2}:)?\d{1,2}:\d{2}$/; +export const vodsSearchParamsSchema = z.object({ + page: z.coerce.number().min(1).max(1_000).catch(1), + weapon: weaponSplId.optional().catch(undefined), + mode: modeShort.optional().catch(undefined), + stageId: stageId.optional().catch(undefined), + type: z.enum(videoMatchTypes).optional().catch(undefined), +}); + +export const userVodsSearchParamsSchema = z.object({ + page: z.coerce.number().min(1).max(1_000).catch(1), +}); + const videoMatchSchema = z.object({ startsAt: z.string().regex(HOURS_MINUTES_SECONDS_REGEX, { message: "Invalid time format. Use HH:MM:SS or MM:SS", diff --git a/app/hooks/useAutoRerender.ts b/app/hooks/useAutoRerender.ts index 4513bca62..ab33991f4 100644 --- a/app/hooks/useAutoRerender.ts +++ b/app/hooks/useAutoRerender.ts @@ -1,18 +1,25 @@ import * as React from "react"; -/** Forces the component to rerender periodically*/ -export function useAutoRerender(every?: "second" | "ten seconds") { - const [, setNow] = React.useState(Date.now()); +/** + * Forces the component to rerender periodically. Returns the current `Date` at + * the time of the latest tick — callers should consume this value (e.g. pass + * it to date-fns) so React Compiler can see the state is observable and won't + * memoize the rerender away. + */ +export function useAutoRerender(every?: "second" | "ten seconds"): Date { + const [now, setNow] = React.useState(() => new Date()); React.useEffect(() => { const intervalTime = !every || every === "second" ? 1000 : 10000; const interval = setInterval(() => { - setNow(Date.now()); + setNow(new Date()); }, intervalTime); return () => { clearInterval(interval); }; }, [every]); + + return now; } diff --git a/app/features/sendouq/q-hooks.ts b/app/hooks/useRecentlyReportedWeapons.ts similarity index 100% rename from app/features/sendouq/q-hooks.ts rename to app/hooks/useRecentlyReportedWeapons.ts diff --git a/app/hooks/useTimeFormat.ts b/app/hooks/useTimeFormat.ts index c6a7d2966..5a111b2ae 100644 --- a/app/hooks/useTimeFormat.ts +++ b/app/hooks/useTimeFormat.ts @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import type { UserPreferences } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import type { LanguageCode } from "~/modules/i18n/config"; import { formatDistanceToNow as formatDistanceToNowUtil } from "~/utils/dates"; @@ -11,6 +12,15 @@ const H24_TIME_OPTIONS: Intl.DateTimeFormatOptions = { hour12: false, hourCycle: "h23" as const, }; + +const DATE_FORMAT_LOCALE: Record< + Exclude, "auto">, + string +> = { + MDY: "en-US", + DMY: "en-GB", + YMD: "sv-SE", +}; function getClockFormatOptions( clockFormat: "auto" | "24h" | "12h" | undefined, language: string, @@ -68,21 +78,38 @@ export function useTimeFormat() { const { i18n } = useTranslation(); const user = useUser(); const clockFormat = user?.preferences?.clockFormat; + const dateFormat = user?.preferences?.dateFormat; const clockOptions = getClockFormatOptions(clockFormat, i18n.language); + const dateLocale = getDateLocale(dateFormat, i18n.language); const formatDateTime = (date: Date, options?: Intl.DateTimeFormatOptions) => { + const adjusted = withYearFirstAdjustment(options, dateFormat); + const useDateLocale = isNumericMonth(adjusted); + const hasTimePart = Boolean(adjusted?.hour); + + // When the user's date-format preference forces a non-language locale (e.g. sv-SE + // for YMD), applying it to the full date+time would also pull in that locale's + // time conventions — most visibly Swedish "fm"/"em" instead of "AM"/"PM". + // Format the date and time portions separately to keep them locale-correct. + if (hasTimePart && useDateLocale && dateLocale !== i18n.language) { + const { hour, minute, second, timeZoneName, ...dateOptions } = adjusted!; + const datePart = date.toLocaleDateString(dateLocale, dateOptions); + const timePart = formatTime(date, { hour, minute, second, timeZoneName }); + return `${datePart}, ${timePart}`; + } + const result = date.toLocaleString( - i18n.language, - options?.hour + useDateLocale ? dateLocale : i18n.language, + hasTimePart ? { - ...options, + ...adjusted, ...clockOptions, } : { - ...options, + ...adjusted, }, ); - return clockOptions.hourCycle === "h23" && options?.hour + return clockOptions.hourCycle === "h23" && hasTimePart ? stripLeadingZeroFromHour(result) : result; }; @@ -104,7 +131,23 @@ export function useTimeFormat() { }; const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions) => { - return date.toLocaleDateString(i18n.language, options); + const adjusted = withYearFirstAdjustment(options, dateFormat); + return date.toLocaleDateString( + isNumericMonth(adjusted) ? dateLocale : i18n.language, + adjusted, + ); + }; + + const formatDateRange = ( + from: Date, + to: Date, + options?: Intl.DateTimeFormatOptions, + ) => { + const adjusted = withYearFirstAdjustment(options, dateFormat); + const locale = isNumericMonth(adjusted) ? dateLocale : i18n.language; + return new Intl.DateTimeFormat(locale, adjusted) + .formatRange(from, to) + .replace(/\s*–\s*/g, " – "); }; /** Same as `formatDateTime` but omits minutes when they are zero and AM/PM format is in use */ @@ -154,6 +197,7 @@ export function useTimeFormat() { formatDateTime, formatTime, formatDate, + formatDateRange, formatDateTimeSmartMinutes, formatDistanceToNow, formatDuration, @@ -165,3 +209,25 @@ export function useTimeFormat() { function stripLeadingZeroFromHour(timeString: string) { return timeString.replace(/\b0(\d:\d{2})/g, "$1"); } + +function getDateLocale( + dateFormat: UserPreferences["dateFormat"] | undefined, + language: string, +) { + if (!dateFormat || dateFormat === "auto") return language; + return DATE_FORMAT_LOCALE[dateFormat]; +} + +function isNumericMonth(options: Intl.DateTimeFormatOptions | undefined) { + if (!options?.month) return false; + return options.month === "numeric" || options.month === "2-digit"; +} + +function withYearFirstAdjustment( + options: Intl.DateTimeFormatOptions | undefined, + dateFormat: UserPreferences["dateFormat"] | undefined, +): Intl.DateTimeFormatOptions | undefined { + if (options?.year !== "2-digit") return options; + if (dateFormat !== "YMD") return options; + return { ...options, year: "numeric" }; +} diff --git a/app/modules/brackets-model/other.ts b/app/modules/brackets-model/other.ts index b08b91e86..d798ce15a 100644 --- a/app/modules/brackets-model/other.ts +++ b/app/modules/brackets-model/other.ts @@ -44,6 +44,9 @@ export interface ParticipantResult { /** How many points in total participant scored in total this set. KO = 100 points. Getting KO'd = 0 points. */ totalPoints?: number; + /** How many KO wins (100-0 games) the participant scored in this set. */ + totalKos?: number; + /** Tells what is the result of a duel for this participant. */ result?: Result; } diff --git a/app/modules/i18n/loader.ts b/app/modules/i18n/loader.ts index 7b5fa2065..ce39bc62e 100644 --- a/app/modules/i18n/loader.ts +++ b/app/modules/i18n/loader.ts @@ -27,7 +27,6 @@ export function i18nLoader() { caches: [], }, // without this hydration fails in E2E tests - initImmediate: false, - showSupportNotice: false, + initAsync: false, }); } diff --git a/app/routes.ts b/app/routes.ts index 7f08a1e43..b5f98153e 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -26,6 +26,10 @@ const devOnlyRoutes = "features/bracket-test/routes/bracket-test.tsx", [index("features/bracket-test/routes/bracket-test.index.tsx")], ), + route( + "/match-page-test", + "features/match-page-test/routes/match-page-test.tsx", + ), ] satisfies RouteConfig) : []; @@ -145,7 +149,7 @@ export default [ ), route( "matches/:mid", - "features/tournament-bracket/routes/to.$id.matches.$mid.tsx", + "features/tournament-match/routes/to.$id.matches.$mid.tsx", ), ]), route("luti", "features/tournament/routes/luti.ts"), @@ -242,6 +246,7 @@ export default [ route("/admin", "features/admin/routes/admin.tsx"), route("/api/chat-users", "features/chat/routes/api.chat-users.ts"), + route("/room", "features/chat/routes/room.ts"), route("/api", "features/api/routes/api.tsx"), ...prefix("/a", [ diff --git a/app/routines/closeExpiredContinueVotes.test.ts b/app/routines/closeExpiredContinueVotes.test.ts new file mode 100644 index 000000000..b363e71a9 --- /dev/null +++ b/app/routines/closeExpiredContinueVotes.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ + send: vi.fn(), + removeRoom: vi.fn(), + setMetadata: vi.fn(), +})); + +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; + +const ALPHA_USER_IDS = [1, 2, 3, 4] as const; +const BRAVO_USER_IDS = [5, 6, 7, 8] as const; + +const insertGroup = async ({ + matchmade, + userIds, +}: { + matchmade: 0 | 1; + userIds: readonly number[]; +}) => { + const group = await db + .insertInto("Group") + .values({ + chatCode: `chat-${Math.random().toString(36).slice(2, 10)}`, + inviteCode: `inv-${Math.random().toString(36).slice(2, 10)}`, + status: "INACTIVE", + matchmade, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .insertInto("GroupMember") + .values( + userIds.map((userId, i) => ({ + groupId: group.id, + userId, + role: i === 0 ? ("OWNER" as const) : ("REGULAR" as const), + })), + ) + .execute(); + + return group.id; +}; + +const insertMatch = async ({ + alphaGroupId, + bravoGroupId, + confirmedAtSeconds, +}: { + alphaGroupId: number; + bravoGroupId: number; + confirmedAtSeconds: number; +}) => { + await db + .insertInto("GroupMatch") + .values({ + alphaGroupId, + bravoGroupId, + chatCode: "test-match-chat", + confirmedAt: confirmedAtSeconds, + }) + .execute(); +}; + +const fetchVotes = (groupId: number) => + db + .selectFrom("GroupMatchContinueVote") + .selectAll() + .where("groupId", "=", groupId) + .execute(); + +describe("CloseExpiredContinueVotesRoutine", () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00Z")); + dbReset(); + await dbInsertUsers(8); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const nowSeconds = () => Math.floor(Date.now() / 1000); + + test("flips all non-NO members to NO for matchmade groups whose match confirmed over 1h ago", async () => { + const alphaGroupId = await insertGroup({ + matchmade: 1, + userIds: ALPHA_USER_IDS, + }); + const bravoGroupId = await insertGroup({ + matchmade: 1, + userIds: BRAVO_USER_IDS, + }); + await insertMatch({ + alphaGroupId, + bravoGroupId, + confirmedAtSeconds: nowSeconds() - 60 * 60 * 2, + }); + await db + .insertInto("GroupMatchContinueVote") + .values([ + { groupId: alphaGroupId, userId: 1, isContinuing: 1 }, + { groupId: alphaGroupId, userId: 2, isContinuing: 1 }, + ]) + .execute(); + + await CloseExpiredContinueVotesRoutine.run(); + + const alphaVotes = await fetchVotes(alphaGroupId); + const bravoVotes = await fetchVotes(bravoGroupId); + + expect(alphaVotes).toHaveLength(4); + expect(alphaVotes.every((v) => v.isContinuing === 0)).toBe(true); + expect(new Set(alphaVotes.map((v) => v.userId))).toEqual( + new Set(ALPHA_USER_IDS), + ); + + expect(bravoVotes).toHaveLength(4); + expect(bravoVotes.every((v) => v.isContinuing === 0)).toBe(true); + }); + + test("leaves matches confirmed under 1h ago untouched", async () => { + const alphaGroupId = await insertGroup({ + matchmade: 1, + userIds: ALPHA_USER_IDS, + }); + const bravoGroupId = await insertGroup({ + matchmade: 1, + userIds: BRAVO_USER_IDS, + }); + await insertMatch({ + alphaGroupId, + bravoGroupId, + confirmedAtSeconds: nowSeconds() - 60 * 30, + }); + await db + .insertInto("GroupMatchContinueVote") + .values({ groupId: alphaGroupId, userId: 1, isContinuing: 1 }) + .execute(); + + await CloseExpiredContinueVotesRoutine.run(); + + const alphaVotes = await fetchVotes(alphaGroupId); + expect(alphaVotes).toHaveLength(1); + expect(alphaVotes[0].isContinuing).toBe(1); + expect(await fetchVotes(bravoGroupId)).toHaveLength(0); + }); + + test("does not touch non-matchmade groups even if match confirmed long ago", async () => { + const alphaGroupId = await insertGroup({ + matchmade: 0, + userIds: ALPHA_USER_IDS, + }); + const bravoGroupId = await insertGroup({ + matchmade: 0, + userIds: BRAVO_USER_IDS, + }); + await insertMatch({ + alphaGroupId, + bravoGroupId, + confirmedAtSeconds: nowSeconds() - 60 * 60 * 2, + }); + + await CloseExpiredContinueVotesRoutine.run(); + + expect(await fetchVotes(alphaGroupId)).toHaveLength(0); + expect(await fetchVotes(bravoGroupId)).toHaveLength(0); + }); + + test("skips groups whose cascade is fully resolved (every member already has a vote row)", async () => { + const alphaGroupId = await insertGroup({ + matchmade: 1, + userIds: ALPHA_USER_IDS, + }); + const bravoGroupId = await insertGroup({ + matchmade: 1, + userIds: BRAVO_USER_IDS, + }); + await insertMatch({ + alphaGroupId, + bravoGroupId, + confirmedAtSeconds: nowSeconds() - 60 * 60 * 2, + }); + await db + .insertInto("GroupMatchContinueVote") + .values( + ALPHA_USER_IDS.map((userId) => ({ + groupId: alphaGroupId, + userId, + isContinuing: 1 as const, + })), + ) + .execute(); + + await CloseExpiredContinueVotesRoutine.run(); + + const alphaVotes = await fetchVotes(alphaGroupId); + expect(alphaVotes.every((v) => v.isContinuing === 1)).toBe(true); + }); +}); diff --git a/app/routines/closeExpiredContinueVotes.ts b/app/routines/closeExpiredContinueVotes.ts new file mode 100644 index 000000000..231d92a65 --- /dev/null +++ b/app/routines/closeExpiredContinueVotes.ts @@ -0,0 +1,18 @@ +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +export const CloseExpiredContinueVotesRoutine = new Routine({ + name: "CloseExpiredContinueVotes", + func: async () => { + const { numAffectedGroups, chatCodesToRevalidate } = + await SQGroupRepository.closeExpiredContinueVotes(); + + for (const room of new Set(chatCodesToRevalidate)) { + ChatSystemMessage.send({ room, revalidateOnly: true }); + } + + logger.info(`Closed continue votes for ${numAffectedGroups} group(s)`); + }, +}); diff --git a/app/routines/deleteOldRoomLinks.ts b/app/routines/deleteOldRoomLinks.ts new file mode 100644 index 000000000..e58f701c4 --- /dev/null +++ b/app/routines/deleteOldRoomLinks.ts @@ -0,0 +1,11 @@ +import * as RoomLinkRepository from "../features/chat/RoomLinkRepository.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +export const DeleteOldRoomLinksRoutine = new Routine({ + name: "DeleteOldRoomLinks", + func: async () => { + const { numDeletedRows } = await RoomLinkRepository.deleteOld(); + logger.info(`Deleted ${numDeletedRows} old room links`); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index 56ad38ca6..f33069846 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -1,6 +1,8 @@ import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions"; +import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods"; import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications"; +import { DeleteOldRoomLinksRoutine } from "./deleteOldRoomLinks"; import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags"; import { NotifyCheckInStartRoutine } from "./notifyCheckInStart"; import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting"; @@ -26,6 +28,8 @@ export const everyHourAt00 = [ export const everyHourAt30 = [ SetOldGroupsAsInactiveRoutine, UpdatePatreonDataRoutine, + CloseExpiredContinueVotesRoutine, + DeleteOldRoomLinksRoutine, ]; /** List of Routines that should occur daily */ diff --git a/app/routines/notifyScrimStartingSoon.ts b/app/routines/notifyScrimStartingSoon.ts index 436b490ec..8b72c3f25 100644 --- a/app/routines/notifyScrimStartingSoon.ts +++ b/app/routines/notifyScrimStartingSoon.ts @@ -2,7 +2,6 @@ import { add, sub } from "date-fns"; import { notify } from "../features/notifications/core/notify.server"; import * as Scrim from "../features/scrims/core/Scrim"; import * as ScrimPostRepository from "../features/scrims/ScrimPostRepository.server"; -import { databaseTimestampToJavascriptTimestamp } from "../utils/dates"; import { logger } from "../utils/logger"; import { Routine } from "./routine.server"; @@ -19,23 +18,30 @@ export const NotifyScrimStartingSoonRoutine = new Routine({ }); for (const scrim of scrims) { - const participantIds = Scrim.participantIdsListFromAccepted(scrim); + const acceptedRequest = scrim.requests.find((r) => r.isAccepted); + if (!acceptedRequest) continue; + + const postTeamName = Scrim.sideDisplayName(scrim); + const requestTeamName = Scrim.sideDisplayName(acceptedRequest); logger.info( - `Notifying scrim starting soon for scrim ${scrim.id} with ${participantIds.length} participants`, + `Notifying scrim starting soon for scrim ${scrim.id} with ${scrim.users.length + acceptedRequest.users.length} participants`, ); await notify({ notification: { type: "SCRIM_STARTING_SOON", - meta: { - id: scrim.id, - at: databaseTimestampToJavascriptTimestamp( - Scrim.getStartTime(scrim), - ), - }, + meta: { id: scrim.id, opponentTeamName: requestTeamName }, }, - userIds: participantIds, + userIds: scrim.users.map((u) => u.id), + }); + + await notify({ + notification: { + type: "SCRIM_STARTING_SOON", + meta: { id: scrim.id, opponentTeamName: postTeamName }, + }, + userIds: acceptedRequest.users.map((u) => u.id), }); } }, diff --git a/app/styles/utils.css b/app/styles/utils.css index f64367cf3..ac7113a12 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -156,6 +156,10 @@ height: 100%; } + .h-max { + height: max-content; + } + .w-full { width: 100%; } @@ -368,6 +372,10 @@ margin: 0 auto; } + .line-height-tight { + line-height: 1.3; + } + .hidden { display: none; } diff --git a/app/styles/vars.css b/app/styles/vars.css index 5d1411bd2..f4ae19814 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -230,6 +230,7 @@ html { --s-1: calc(var(--_size-spacing) * 1 * 0.25rem); --s-1-5: calc(var(--_size-spacing) * 1.5 * 0.25rem); --s-2: calc(var(--_size-spacing) * 2 * 0.25rem); + --s-2-5: calc(var(--_size-spacing) * 2.5 * 0.25rem); --s-3: calc(var(--_size-spacing) * 3 * 0.25rem); --s-4: calc(var(--_size-spacing) * 4 * 0.25rem); --s-5: calc(var(--_size-spacing) * 5 * 0.25rem); diff --git a/app/utils/flip.ts b/app/utils/flip.ts deleted file mode 100644 index 91cec981e..000000000 --- a/app/utils/flip.ts +++ /dev/null @@ -1,19 +0,0 @@ -// https://github.com/aholachek/react-flip-toolkit/issues/95#issuecomment-546101332 -/** - * Thin wrapper around Element.animate() that returns a Promise - * @param el Element to animate - * @param keyframes The keyframes to use when animating - * @param options Either the duration of the animation or an options argument detailing how the animation should be performed - * @returns A promise that will resolve after the animation completes or is cancelled - */ -export function animate( - el: HTMLElement, - keyframes: Keyframe[] | PropertyIndexedKeyframes, - options?: number | KeyframeAnimationOptions, -): Promise { - return new Promise((resolve) => { - const anim = el.animate(keyframes, options); - anim.addEventListener("finish", () => resolve()); - anim.addEventListener("cancel", () => resolve()); - }); -} diff --git a/app/utils/json.ts b/app/utils/json.ts deleted file mode 100644 index a9b38ef1d..000000000 --- a/app/utils/json.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function safeJSONParse(json: string, defaultValue: T): T { - try { - return JSON.parse(json); - } catch { - return defaultValue; - } -} diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts new file mode 100644 index 000000000..133e65371 --- /dev/null +++ b/app/utils/remix.server.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { redirectIfPageOutOfBounds } from "./remix.server"; + +const buildRequest = (url: string) => new Request(url); + +const captureRedirect = (fn: () => void) => { + try { + fn(); + } catch (thrown) { + if (thrown instanceof Response) return thrown; + throw thrown; + } + return null; +}; + +describe("redirectIfPageOutOfBounds()", () => { + it("does not redirect when page is within bounds", () => { + const response = captureRedirect(() => + redirectIfPageOutOfBounds({ + request: buildRequest("https://sendou.ink/vods?page=2"), + page: 2, + pagesCount: 5, + }), + ); + + expect(response).toBeNull(); + }); + + it("does not redirect when page equals pagesCount", () => { + const response = captureRedirect(() => + redirectIfPageOutOfBounds({ + request: buildRequest("https://sendou.ink/vods?page=5"), + page: 5, + pagesCount: 5, + }), + ); + + expect(response).toBeNull(); + }); + + it("redirects to last page when page exceeds pagesCount", () => { + const response = captureRedirect(() => + redirectIfPageOutOfBounds({ + request: buildRequest("https://sendou.ink/vods?page=99"), + page: 99, + pagesCount: 5, + }), + ); + + expect(response).not.toBeNull(); + expect(response?.headers.get("Location")).toBe("/vods?page=5"); + }); + + it("preserves other search params when redirecting", () => { + const response = captureRedirect(() => + redirectIfPageOutOfBounds({ + request: buildRequest( + "https://sendou.ink/vods?type=TOURNAMENT&page=99&mode=SZ", + ), + page: 99, + pagesCount: 3, + }), + ); + + const location = response?.headers.get("Location"); + expect(location).not.toBeNull(); + const locationUrl = new URL(location!, "https://sendou.ink"); + expect(locationUrl.pathname).toBe("/vods"); + expect(locationUrl.searchParams.get("page")).toBe("3"); + expect(locationUrl.searchParams.get("type")).toBe("TOURNAMENT"); + expect(locationUrl.searchParams.get("mode")).toBe("SZ"); + }); + + it("does not redirect on page 1 when pagesCount is 0 (empty results)", () => { + const response = captureRedirect(() => + redirectIfPageOutOfBounds({ + request: buildRequest("https://sendou.ink/vods?page=1"), + page: 1, + pagesCount: 0, + }), + ); + + expect(response).toBeNull(); + }); + + it("redirects to page 1 when pagesCount is 0 and page exceeds 1", () => { + const response = captureRedirect(() => + redirectIfPageOutOfBounds({ + request: buildRequest("https://sendou.ink/vods?page=4"), + page: 4, + pagesCount: 0, + }), + ); + + expect(response?.headers.get("Location")).toBe("/vods?page=1"); + }); +}); diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 58afdcaa1..4c4c786a6 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -57,6 +57,29 @@ export function parseSearchParams({ } } +/** + * If the requested `page` exceeds `pagesCount`, throws a redirect to the last + * available page (preserving other search params). `pagesCount` is normalized + * to a minimum of 1 so empty result sets stay on page 1. + */ +export function redirectIfPageOutOfBounds({ + request, + page, + pagesCount, +}: { + request: Request; + page: number; + pagesCount: number; +}): void { + const safePagesCount = Math.max(1, pagesCount); + if (page <= safePagesCount) return; + + const url = new URL(request.url); + const searchParams = new URLSearchParams(url.searchParams); + searchParams.set("page", String(safePagesCount)); + throw redirect(`${url.pathname}?${searchParams.toString()}`); +} + export function parseSafeSearchParams({ request, schema, diff --git a/app/utils/string.test.ts b/app/utils/string.test.ts index 34c57ce94..61c97ac2c 100644 --- a/app/utils/string.test.ts +++ b/app/utils/string.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { pathnameFromPotentialURL, truncateBySentence } from "./strings"; +import { + pathnameFromPotentialURL, + removeMarkdown, + truncateBySentence, +} from "./strings"; describe("pathnameFromPotentialURL()", () => { test("Resolves path name from valid URL", () => { @@ -44,3 +48,30 @@ describe("truncateBySentence()", () => { expect(truncateBySentence(text, 20)).toBe("First line"); }); }); + +describe("removeMarkdown()", () => { + test("Decodes   entities and collapses runs", () => { + const text = "     Global Gauntlet is an event"; + expect(removeMarkdown(text)).toBe("Global Gauntlet is an event"); + }); + + test("Decodes common named HTML entities", () => { + expect(removeMarkdown("Tom & Jerry <3 "hi"")).toBe( + 'Tom & Jerry <3 "hi"', + ); + }); + + test("Decodes numeric HTML entities", () => { + expect(removeMarkdown("café & tea")).toBe("café & tea"); + }); + + test("Leaves unknown named entities untouched", () => { + expect(removeMarkdown("AT&T &fakeentity; rules")).toBe( + "AT&T &fakeentity; rules", + ); + }); + + test("Strips HTML tags and markdown emphasis", () => { + expect(removeMarkdown("

Hello **world**!

")).toBe("Hello world!"); + }); +}); diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 60aac7e53..c38d5e409 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -76,12 +76,35 @@ export function truncateBySentence(value: string, max: number) { } // based on https://github.com/zuchka/remove-markdown +const NAMED_HTML_ENTITIES: Record = { + nbsp: " ", + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", +}; + export function removeMarkdown(value: string) { const htmlReplaceRegex = /<[^>]*>/g; return ( value // Remove HTML tags .replace(htmlReplaceRegex, "") + // Decode named HTML entities (e.g.  , &) + .replace(/&([a-zA-Z]+);/g, (match, name: string) => { + const replacement = NAMED_HTML_ENTITIES[name.toLowerCase()]; + return replacement ?? match; + }) + // Decode numeric HTML entities (e.g.   or  ) + .replace(/&#(x?[0-9a-fA-F]+);/g, (_, code: string) => { + const codePoint = code.startsWith("x") + ? Number.parseInt(code.slice(1), 16) + : Number.parseInt(code, 10); + return Number.isFinite(codePoint) + ? String.fromCodePoint(codePoint) + : ""; + }) // Remove setext-style headers .replace(/^[=-]{2,}\s*$/g, "") // Remove footnotes? @@ -113,5 +136,8 @@ export function removeMarkdown(value: string) { // .replace(/(\S+)\n\s*(\S+)/g, '$1 $2') // Replace strike through .replace(/~(.*?)~/g, "$1") + // Collapse runs of whitespace (e.g. from decoded   or stripped tags) + .replace(/[ \t ]{2,}/g, " ") + .trim() ); } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 557bb9984..0e360d619 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -495,6 +495,8 @@ export const modeImageUrl = (mode: ModeShortWithSpecial) => `/static-assets/img/modes/${mode}`; export const stageImageUrl = (stageId: StageId) => `/static-assets/img/stages/${stageId}`; +export const stageBannerImageUrl = (stageId: StageId) => + `/static-assets/img/stage-banners/${stageId}.avif`; export const tierImageUrl = (tier: TierName | "CALCULATING") => `/static-assets/img/tiers/${tier.toLowerCase()}`; export const controllerImageUrl = (controller: string) => diff --git a/content/articles/sws26-recap-gg1-q2.md b/content/articles/na-league-2026-r1e3.md similarity index 100% rename from content/articles/sws26-recap-gg1-q2.md rename to content/articles/na-league-2026-r1e3.md diff --git a/content/articles/sws26recap-gg1-q2.md b/content/articles/sws26recap-gg1-q2.md new file mode 100644 index 000000000..0742841b2 --- /dev/null +++ b/content/articles/sws26recap-gg1-q2.md @@ -0,0 +1,187 @@ +--- +title: "SWS26 Recap: Global Gauntlet 1, Qualifier 2" +date: 2026-04-23 +author: + - name: YELLOW + link: https://sendou.ink/u/great-hero-yellow +--- + +#### *To no surprise, PxG qualifies for the Global Gauntlet Finals—more interestingly, who’s going with them?* + +SWS26Recap_GG1Q2 + +On Saturday, April 18, 2026, the second qualifier for IPL and AREA CUP’s global tournament series, the Global Gauntlet, took place to decide the final three Western teams who will compete at the First Edition Global Gauntlet Finals. + +Last week, the first three teams—FTWin, ezmd, and Azure—earned their seats, and all six teams from Japan have been revealed by this point. You’ll find all of the teams participating at the end of the article\! + +The double-elimination, Splat Zones-only tournament saw 15 teams step into the ring, including heavy-hitters like PxG, Sun-Eater, healthy diet food groups, and nm. Popgun and Sasu commentated the action, and Ely was on the spectator camera. + +After nearly three and a half hours of action, what went down? + +## **Winner’s Round 1: healthy diet food groups vs. Sooo nights at a lan 😂 (1–2)** + +Kicking off Qualifier 2 was healthy diet food groups, returning from Qualifier 1, and Sooo nights at a lan, a pickup with players who were on team hi last qualifier. The first two rounds of the event were Best of 3 sets. + +Game one, the random map selection, went to Hammerhead Bridge, after healthy diet food groups banned Flounder Heights and Crableg Capital, and Sooo nights at a lan banned Humpback Pump Track and Manta Maria. + +Healthy diet food groups was the first team to take the zone, after 35 seconds of fighting over it in neutral. Midway through the game, Sooo nights at a lan got close to taking the lead, but needed a second push to get it, and began to lock out once there. Healthy diet food groups prevented the knockout, but still lost the game, 76–88. + +Game two went to Museum d’Alfonsino, after Sooo nights at a lan banned Sturgeon Shipyard and Shipshape Cargo Co. + +Once more, healthy diet good groups started with the zone. They didn’t get too far before their opponent took the zone, then the lead. After going down two players each, healthy diet food groups ended up back in the lead, and this would flip-flop a few times before a tense clash in the final seconds where healthy diet food groups stopped Sooo nights at a lan and took the win, 85–81. + +WR1_museum + +*Just five ticks from losing the lead, healthy diet food groups neutralized the zone and would quickly cap it, just before the game ended, securing their win.* + +Game three went to Brinewater Springs; Mincemeat Metalworks and Eeltail Alley were healthy diet food groups’ map strikes. + +Sooo nights at a lan, who selected this map, chose well; they kept the game in their hands for pretty much the whole, short match. After wiping healthy diet food groups out after two minutes had passed, the game ended in a knockout victory for them, and they advanced to the next round. + +## **Winner’s Round 2 \- Black Bull ♣️🐂 vs. NEVER BACK DOWN NEVER WHAT (2–0)** + +The second round in Winner’s Bracket was between Black Bull, a pickup of Black Lotus players, and NEVER BACK DOWN NEVER WHAT, another returning face from Qualifier 1\. This set was still a Best of 3, but the next would be a Best of 5\. + +Game one, Brinewater Springs, was randomly chosen after Black Bull banned Eeltail Alley and Crableg Capital, and NEVER BACK DOWN NEVER WHAT banned Urchin Underpass and Humpback Pump Track. + +The game began with NEVER BACK DOWN NEVER WHAT taking the zone, but within the first 30 seconds, Black Bull took possession and the lead. Black Bull’s run was stopped at 49, but was just a small bump in the road, as they ended up knocking out just past the three-minute mark. + +Game two, Mahi-Mahi Resort, was NEVER BACK DOWN NEVER WHAT’s selection after Black Bull banned Museum d’Alfonsino and Mincemeat Metalworks. + +Once more, NEVER BACK DOWN NEVER WHAT led the game from the start, taking Black Bull down three players, getting all the way to 11 before Black Bull set up a Big Bubbler in the corner to safely break their way into the zone. Just when 11 sounded like a safe lead, Black Bull got the 100-to-0, taking the game just one second slower than the previous one. + +WR2_mahi-mahi + +*SOUL WORLD rallies their Black Bull teammates to the Big Bubbler, initiating the play that would lead the team to a 100-to-0 victory.* + +## **Winner’s Semifinals Part 1 \- PxG vs. French \+ Kenshin (3–1)** + +The bracket skipped over Winner’s Quarterfinals and went right to Winner’s Semifinals, transitioning from a Best of 3 to Best of 5\. PxG was set up to face French \+ Kenshin, for the first of Qualifier 2’s three tickets to the First Edition Global Gauntlet Finals. + +Game one was at Hagglefish Market, chosen after PxG banned Urchin Underpass and Barnacle & Dime, and French \+ Kenshin banned Crableg Capital and Hammerhead Bridge. + +French \+ Kenshin claimed the zone first and was able to get to a respectable score of 61 before PxG locked in. Two minutes into the game, PxG took the lead, being delayed at 34 before knocking out with just over two minutes to spare. + +Game two was at Mahi-Mahi Resort; PxG banned Um’ami Ruins and Undertow Spillway. + +A disconnect (which would be some foreshadowing for later) caused the game to reset, and once it started in full, PxG’s opening saw them hold the zone to 34, where French \+ Kenshin finally capped the zone after several attempts of only neutralizing it. French \+ Kenshin was wiped out by the two-minute mark, and seconds later, PxG had another knockout. + +French \+ Kenshin’s game three selection was Inkblot Art Academy, after PxG banned Shipshape Cargo Co. and Wahoo World. + +French \+ Kenshin’s opening push got them incredibly far, to 25, before PxG could take the zone. It took PxG about a minute to take the lead, and they were so close to knocking out, but were stopped at 4\. With one minute remaining, French \+ Kenshin at 9 and PxG at 4, Gos disconnected, and PxG was not able to maintain their lead, losing it in the final 20 seconds, and French \+ Kenshin had their own knockout in the books. + +WSF_inkblot + +*PxG held the lead in the 3v4 for an impressive amount of time, but one E-liter 4K can’t keep the zone against three opponents.* + +PxG counterpicked to MakoMart after French \+ Kenshin banned Museum d’Alfonsino and Mincemeat Metalworks. + +PxG came back with a vengeance in this game, wiping out their opponent twice in the first minute. PxG made it to 21 before French \+ Kenshin took the zone from them, but only for four points before they cycled being down two players, and ultimately, PxG took the final knockout, securing their \#4 spot at the Global Gauntlet Finals\! + +## **Winner’s Semifinals Part 2: Sooo nights at a lan 😂 vs. Black Bull ♣️🐂 (2–3)** + +On the other side of the bracket, for the second ticket to the GG Finals, was Sooo nights at a lan and Black Bull, for their second time each on stream. Sooo nights at a lan (seed 10\) has upset seeds 7 (healthy diet food groups) and 2 (nm) to get this far, and was looking to upset another in Black Bull, who was seed 3\. + +Urchin Underpass was game one’s map; Black Bull banned Inkblot Art Academy and Museum d’Alfonsino, and Sooo nights at a lan banned Wahoo World and Manta Maria. + +Sooo nights at a lan started the match with the zone, but by the end of the first minute, Black Bull had the zone and lead, ticking down points, and almost knocked out, finally losing the zone at 2\. With 70 ticks and 24 penalty points, Sooo nights at a lan went the distance in one sweep, knocking out as the clock read 2:10. + +WSF2_urchin + +Game two went to Um’ami Ruins by Black Bull’s choice, seeing strikes on Hammerhead Bridge and Mincemeat Metalworks. + +The first major lead in the game occurred after Sooo nights at a lan, who only had a lead of 1 point, won a 20-second firefight over the neutral zones, and was able to get to 55 before Black Bull had their turn. Black Bull locked out their opponent, taking them down three players to get the lead, and knocked out for the win. + +Game three went to Scorch Gorge as Sooo nights at a lan’s counterpick after Black Bull banned Humpback Pump Track and Shipshape Cargo Co. + +It took 40 seconds for the zone to be capped; Sooo nights at a lan had it up until they reached 42\. After having the zone for about 30 seconds, Black Bull was in the lead and extended it to 19\. Both teams started to trade possession, and Sooo nights at a lan tied with Black Bull, unfortunately ending up one point behind after they lost the zone. However, they came back and wiped out Black Bull, getting the win with time to spare. + +Game four, Black Bull’s counterpick, went to Robo ROM-en. Sooo nights at a lan banned Bluefin Depot and Crableg Capital. + +Sooo nights at a lan had a strong lead, keeping ahead of Black Bull even after they took the zone. Black Bull eventually got their lead after wiping out Sooo nights at a lan, and their opposition couldn’t regroup and retake, resulting in another knockout for Black Bull. + +After trading wins, the set was tied 2–2, going to a game five, where Black Bull banned MakoMart and Undertow Spillway. Sooo nights at a lan selected Hagglefish Market. + +First Black Bull had the lead, then Sooo nights at a lan had the lead, both in the same minute. The zone flipped back and forth, finally settling with Black Bull, who sealed the game with a Triple Splashdown for the knockout, set win, and \#5 spot at the GG Finals\! + +## **Loser’s Round 4: Sun-Eater vs. Sooo nights at a lan 😂 (1–2)** + +Once the Winner’s Bracket concluded, the stream would see two sets in the Loser’s Bracket to determine the final Western team for the GG Finals. Since this was not Quarterfinals, it was another Best of 3 set. + +As some fun trivia provided by Ely, we learned that Sooo nights at a lan was named that because one of their players, Nightmare, couldn’t play in the tournament due to playing at the Sunset Surge LAN. + +Game one, Mahi-Mahi Resort, was selected from the map pool after Sun-Eater banned Shipshape Cargo Co. and Marlin Airport and Sooo nights at a lan banned Crableg Capital and Hammerhead Bridge. + +Although Sun-Eater began with the zone, they lost it quickly. The water on the map was already dropping before the first minute was up, as Sooo nights at a lan extended their lead. One minute and a half into the game, Sooo nights at a lan knocked Sun-Eater out. + +Much to the dismay of the commentators, Sun-Eater selected Wahoo World for game two. Bluefin Depot and Museum d’Alfonsino were banned. + +Sooo nights at a lan’s recurring tactic was to use their Crab Tank to paint the zone offensively, and again and again it worked against Sun-Eater. Each time, Sun-Eater bounced back stronger, ending up back in the lead. In the final seconds, they secured their win by taking the zone back, and the final score was 77–63. + +LR4_wahooworld + +*The Crab Tank that caused Sun-Eater so much trouble, just one of the many instances where it both protected and painted the zone.* + +Game three, determining who went to Loser’s Semifinals, went to MakoMart after Sun-Eater banned Humpback Pump Track and Flounder Heights. + +Sun-Eater started strong, holding the zone for most of the first minute. While their respawns kept staggering, Sooo nights at a lan capitalized to take the lead. Both teams kept one-upping the other with the lead. The only game to go into overtime, and Sun-Eater was just one point away from winning before Sooo nights at a lan overpowered them, winning 91–90 and advancing to face nm in Loser’s Semifinals. + +## **Loser’s Semifinals: Sooo nights at a lan 😂 vs. nm (0–3)** + +The final ticket to the GG Finals on April 24th\! This was not the first time Sooo nights at a lan met nm in this tournament—in fact, Sooo nights at a lan was the team who sent nm to the Loser’s Bracket 2-1 in Winner’s Round 2\. While Sooo nights at a lan had taken every set thus far to the last possible game, nm meanwhile had torn through the Loser’s Bracket 2–0 in every set. + +While waiting for the set to start, nm was in the Twitch chat talking with Popgun and Sasu, and revealed that the name “nm” stands for “No Mercy”, which was exactly what they were about to put on display. + +Game one went to Sturgeon Shipyard. Nm banned Humpback Pump Track and Undertow Spillway. Sooo nights at a lan banned Hagglefish Market and Manta Maria. + +Early on, nm claimed the zone, taking down two players on the opposing team. Sooo nights at a lan tried to paint with their Crab Tank, but it was shot down before it could even neutralize the zone. Going 100-to-0, nm ended the first game in a knockout. This was the first game one in the entire tournament that Sooo nights at a lan didn’t win. + +MakoMart was selected for game two after nm banned Flounder Heights and Um’ami Ruins. + +Nm guarded the zone so well that Sooo nights at a lan could only take shots at the objective from afar. Sooo nights at a lan was able to take the zone a few times this game, but never held it for long. After a wipeout, nm earned another knockout victory. + +LSF_makomart + +*Sooo nights at a lan, using their Crab Tank to try breaking into the zone, only for it to be taken out momentarily by an Ultra Stamp.* + +Game three, match point for nm, went to Brinewater Springs after Urchin Underpass and Inkblot Art Academy were banned. + +Leading the charge with an Ink Vac, nm took the zone first. After going down two players, Sooo nights at a lan had a chance to take the zone and keep their opponent at 40 points. They took their turn with the lead, defended it against nm a few times, but nm had the last say, wresting control and getting one final, decisive knockout to end the set 3–0 and take the \#6 spot on the West’s GG Finals roster\! + +### **The First Edition Global Gauntlet Finals: Who’s Going?** + +The First Edition Global Gauntlet Finals takes place during the International Timeslot, which is 8 PM PT / 11 PM ET on Friday, April 24th for North America and 5 AM CET / 12 PM JT on Saturday, April 25th for the EU and Japan. + +From the West, the six teams have been decided: + +1. FTWin +2. ezmd +3. Azure +4. PxG +5. Black Lotus (Black Bull) +6. No Mercy (nm) + +From Japan, AREA CUP has revealed all six teams attending: + +1. Zest +2. Utopia +3. 07 Quartet +4. EmpEror +5. swing +6. ISM rhythm + +EmpEror, a newly-announced addition to Japan’s GG Finals team, is composed of Lobster and Naegora, two champions from Splat World Series 2025’s \#1 team The Invincible Fleet Rei Maru, along with Neespa and Chiaki. + +Speaking of The Invincible Fleet Rei Maru, their player Reimaru is competing as part of ‘swing’, a group made up of Momo and Norishio from last SWS’s team DragonReX, Reimaru, and Streamer Sena. Momo recently played in the SendouQ Season 10 Finale last month on Flow Dragons, with Black Lotus’s captain, Noctis, and took the second place silver, next to PxG’s first place gold. + +The last-announced team, revealed one day before the Finals take place, ISM rhythm, has the final player from The Invincible Fleet Rei Maru, Grandroll, playing with new teammates Livia, Yocchan Ika, and Goyame. + +Following the Splat World Series last year, the West has made a strong showing of taking part in more Japanese events, such as AREA CUP, or just meeting together for scrims. The Global Gauntlets, created as a warm up to prepare both sides for the Splat World Series, will give players much to strategize over after the premiere Finals event. + +Last time teams of this caliber met, only PxG walked away with a win over a Japanese team. How has the West used their time to close the gap? + +Be there on April 24th to find out\! + +Original Posting Date: April 23, 2026 at [Splatoon Stronghold](https://www.splatoonstronghold.com/news/sws26-recap-gg1-q2). + +Written and formatted for publication by [YELLOW](https://bsky.app/profile/great-hero-yellow.bsky.social). diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 75ffffbc7..3d80eaeaf 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/docs/dev/forms.md b/docs/dev/forms.md index 51fd0cd7c..e713c56d4 100644 --- a/docs/dev/forms.md +++ b/docs/dev/forms.md @@ -574,7 +574,7 @@ Run `pnpm run i18n:sync` after adding English translations to initialize other l Use `createFormHelpers` for type-safe form interactions: ```ts -import { createFormHelpers } from "~/utils/playwright-form"; +import { createFormHelpers } from "./helpers/playwright-form"; import { myFormSchema } from "~/features/my/my-schemas"; test("fills and submits form", async ({ page }) => { @@ -628,7 +628,7 @@ import { selectStage, selectWeapon, selectUser, -} from "~/utils/playwright"; +} from "./helpers/playwright"; ``` ## Complete Example @@ -717,9 +717,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { ### E2E Test (`feature.spec.ts`) ```ts -import { createFormHelpers } from "~/utils/playwright-form"; +import { createFormHelpers } from "./helpers/playwright-form"; import { createItemSchema } from "~/features/item/feature-schemas"; -import { test, navigate, impersonate, seed } from "~/utils/playwright"; +import { test, navigate, impersonate, seed } from "./helpers/playwright"; test("creates new item", async ({ page }) => { await seed(page); diff --git a/e2e/analyzer.spec.ts b/e2e/analyzer.spec.ts index f11744779..d212f68be 100644 --- a/e2e/analyzer.spec.ts +++ b/e2e/analyzer.spec.ts @@ -1,3 +1,4 @@ +import { ANALYZER_URL } from "~/utils/urls"; import { expect, impersonate, @@ -6,8 +7,7 @@ import { seed, selectWeapon, test, -} from "~/utils/playwright"; -import { ANALYZER_URL } from "~/utils/urls"; +} from "./helpers/playwright"; test.describe("Build Analyzer", () => { test("analyzes a build and links to new build page with same abilities", async ({ diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 91435e1af..6819d3b34 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,8 +1,14 @@ import type { Page } from "@playwright/test"; import { ORG_ADMIN_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; import { tournamentTeamPage } from "~/utils/urls"; +import { + expect, + impersonate, + navigate, + seed, + test, +} from "./helpers/playwright"; const ITZ_TOURNAMENT_ID = 2; const ITZ_TEAM_ID = 101; diff --git a/e2e/art.spec.ts b/e2e/art.spec.ts index d0759afa2..0a4746346 100644 --- a/e2e/art.spec.ts +++ b/e2e/art.spec.ts @@ -1,7 +1,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { NZAP_TEST_ID } from "~/db/seed/constants"; -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + test, +} from "./helpers/playwright"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index ef53c7274..183b692ec 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -1,5 +1,6 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { associationsPage, scrimsPage } from "~/utils/urls"; import { expect, impersonate, @@ -8,8 +9,7 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { associationsPage, scrimsPage } from "~/utils/urls"; +} from "./helpers/playwright"; test.describe("Associations", () => { test("creates a new association", async ({ page }) => { diff --git a/e2e/badges.spec.ts b/e2e/badges.spec.ts index e1d5ac585..eb180de73 100644 --- a/e2e/badges.spec.ts +++ b/e2e/badges.spec.ts @@ -1,3 +1,5 @@ +import { badgePage } from "~/utils/urls"; +import { NZAP_TEST_ID } from "../app/db/seed/constants"; import { expect, impersonate, @@ -5,9 +7,7 @@ import { seed, selectUser, test, -} from "~/utils/playwright"; -import { badgePage } from "~/utils/urls"; -import { NZAP_TEST_ID } from "../app/db/seed/constants"; +} from "./helpers/playwright"; test.describe("Badges", () => { test("adds a badge owner sending a notification", async ({ page }) => { diff --git a/e2e/ban.spec.ts b/e2e/ban.spec.ts index 0e1ec89c8..9d1cb54f8 100644 --- a/e2e/ban.spec.ts +++ b/e2e/ban.spec.ts @@ -1,6 +1,7 @@ import type { Page } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { ADMIN_PAGE, SUSPENDED_PAGE } from "~/utils/urls"; import { expect, impersonate, @@ -8,8 +9,7 @@ import { seed, test, waitForPOSTResponse, -} from "~/utils/playwright"; -import { ADMIN_PAGE, SUSPENDED_PAGE } from "~/utils/urls"; +} from "./helpers/playwright"; async function banUser( page: Page, diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index 4b8662188..c4f5041f5 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -4,9 +4,15 @@ import type { GearType } from "~/db/tables"; import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; import { newBuildBaseSchema } from "~/features/user-page/user-page-schemas"; import invariant from "~/utils/invariant"; -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; import { BUILDS_PAGE, userBuildsPage, userNewBuildPage } from "~/utils/urls"; +import { + expect, + impersonate, + navigate, + seed, + test, +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; test.describe("Builds", () => { test("adds a build", async ({ page }) => { diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index df65c7acb..abd0af2a2 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -1,4 +1,5 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { calendarPage } from "~/utils/urls"; import { expect, expectIsHydrated, @@ -7,8 +8,7 @@ import { navigate, seed, test, -} from "~/utils/playwright"; -import { calendarPage } from "~/utils/urls"; +} from "./helpers/playwright"; const SENDOU_INK_TOURNAMENTS_COUNT = 6; diff --git a/e2e/comp-analyzer.spec.ts b/e2e/comp-analyzer.spec.ts index ff536b0ff..6c7d073c3 100644 --- a/e2e/comp-analyzer.spec.ts +++ b/e2e/comp-analyzer.spec.ts @@ -1,5 +1,5 @@ -import { expect, navigate, test } from "~/utils/playwright"; import { COMP_ANALYZER_URL } from "~/utils/urls"; +import { expect, navigate, test } from "./helpers/playwright"; test.describe("Composition Analyzer", () => { test("weapon selection, removal, and URL persistence", async ({ page }) => { diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts index 01df43c2c..488845e6f 100644 --- a/e2e/events.spec.ts +++ b/e2e/events.spec.ts @@ -1,5 +1,11 @@ -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; import { EVENTS_PAGE } from "~/utils/urls"; +import { + expect, + impersonate, + navigate, + seed, + test, +} from "./helpers/playwright"; test.describe("Events", () => { test("filters between tabs and navigates to an event", async ({ page }) => { diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts index c33aba243..b94a23e34 100644 --- a/e2e/friends.spec.ts +++ b/e2e/friends.spec.ts @@ -1,4 +1,5 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { FRIENDS_PAGE } from "~/utils/urls"; import { expect, impersonate, @@ -8,8 +9,7 @@ import { submit, test, waitForPOSTResponse, -} from "~/utils/playwright"; -import { FRIENDS_PAGE } from "~/utils/urls"; +} from "./helpers/playwright"; test.describe("Friends", () => { test("send friend request, accept it, then delete friend", async ({ diff --git a/e2e/global-search.spec.ts b/e2e/global-search.spec.ts index 84cc2be9d..26529a28a 100644 --- a/e2e/global-search.spec.ts +++ b/e2e/global-search.spec.ts @@ -1,4 +1,10 @@ -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + test, +} from "./helpers/playwright"; test.describe("Global search", () => { test("searches for users and organizations", async ({ page }) => { diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 4ee0f06f3..cd9799109 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,7 +1,7 @@ import { type ChildProcess, execSync, spawn } from "node:child_process"; import fs from "node:fs"; import type { FullConfig } from "@playwright/test"; -import { E2E_BASE_PORT } from "~/utils/playwright"; +import { E2E_BASE_PORT } from "./helpers/playwright"; const WORKER_COUNT = Number(process.env.E2E_WORKERS) || 4; const DEBUG = process.env.E2E_DEBUG === "true"; diff --git a/app/utils/playwright-form.ts b/e2e/helpers/playwright-form.ts similarity index 100% rename from app/utils/playwright-form.ts rename to e2e/helpers/playwright-form.ts diff --git a/app/utils/playwright.ts b/e2e/helpers/playwright.ts similarity index 98% rename from app/utils/playwright.ts rename to e2e/helpers/playwright.ts index 1c653180d..e61bb3039 100644 --- a/app/utils/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -7,7 +7,7 @@ import { import dotenv from "dotenv"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import type { SeedVariation } from "~/features/api-private/routes/seed"; -import { tournamentBracketsPage } from "./urls"; +import { tournamentBracketsPage } from "~/utils/urls"; dotenv.config(); export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500; diff --git a/e2e/helpers/tournament-match.ts b/e2e/helpers/tournament-match.ts new file mode 100644 index 000000000..2d036620c --- /dev/null +++ b/e2e/helpers/tournament-match.ts @@ -0,0 +1,116 @@ +import type { Page } from "@playwright/test"; +import { expect, submit, waitForPOSTResponse } from "./playwright"; + +/** + * Helpers for interacting with the tournament match page in e2e tests. + * + * The match page splits its UI into URL-driven tabs (rosters/action/admin/etc.) + * — these helpers handle the navigation so individual tests can stay focused on + * the assertion they care about. + */ + +type Side = 1 | 2; + +export const navigateToMatch = async (page: Page, matchId: number) => { + await expect(async () => { + await page.locator(`[data-match-id="${matchId}"]`).click(); + await expect(page.getByTestId("back-to-bracket-button")).toBeVisible(); + }).toPass(); +}; + +export const backToBracket = async (page: Page) => { + await expect(async () => { + await page.getByTestId("back-to-bracket-button").click(); + await expect(page.getByTestId("brackets-viewer")).toBeVisible(); + }).toPass(); +}; + +export const expectScore = (page: Page, score: [number, number]) => + expect(page.getByText(score.join("-")).first()).toBeVisible(); + +const TAB_LABELS = { + action: "Action", + admin: "Admin", + result: "Result", + rosters: "Rosters", + join: "Join", +} as const; + +export const goToTab = async ( + page: Page, + tab: "action" | "admin" | "result" | "rosters" | "join", +) => { + // When teams have more members than the minimum, the action tab is hidden + // until each team's active roster is locked in via the rosters tab. Auto-set + // any roster that's still in default-editing mode so callers can stay focused + // on the flow they actually care about. + if (tab === "action") { + await ensureActiveRostersSet(page); + } + await page.getByRole("tab", { name: TAB_LABELS[tab] }).click(); +}; + +const ensureActiveRostersSet = async (page: Page) => { + const sides = ["alpha", "bravo"] as const; + + // If the action tab is already there, no rosters need setting. + if ((await page.getByRole("tab", { name: TAB_LABELS.action }).count()) > 0) { + return; + } + + // Editing inputs only render on the rosters tab — switch there. + await page.getByRole("tab", { name: TAB_LABELS.rosters }).click(); + // Wait for the rosters panel to be ready before probing for editing UI. + await expect(page.getByRole("tabpanel", { name: "Rosters" })).toBeVisible(); + + for (const side of sides) { + const submitButton = page.getByTestId(`save-active-roster-button-${side}`); + if ((await submitButton.count()) === 0) continue; + + // Default-editing renders all members unchecked; pick the first 4. + for (let i = 0; i < 4; i++) { + const checkbox = page.getByTestId(`player-checkbox-${side}-${i}`); + if (!(await checkbox.isChecked())) await checkbox.click(); + } + await submit(page, `save-active-roster-button-${side}`); + } +}; + +/** + * Sweeps `mapsToReport` maps in a row, all won by `winner`. By default the + * last map ends the set (the typical case — full Bo3/Bo5 sweep), and the + * helper goes through the confirmation screen for that map. Pass + * `setEnds: false` when reporting a partial set (e.g. only 1 of a Bo3). + */ +export const reportResult = async ( + page: Page, + { + mapsToReport, + winner = 1, + setEnds = true, + }: { mapsToReport: number; winner?: Side; setEnds?: boolean }, +) => { + for (let i = 0; i < mapsToReport; i++) { + const isFinal = setEnds && i === mapsToReport - 1; + // Wait for the action panel to settle before clicking. waitForPOSTResponse + // only waits for the POST itself; the loader revalidation that swaps in + // the next map's component runs after, so a previous winner can still be + // `data-selected="true"` here. Clicking too early hits the about-to-unmount + // label and the selection is lost on remount. + await expect( + page.locator('[data-testid^="winner-radio-"][data-selected="true"]'), + ).toHaveCount(0); + await page.getByTestId(`winner-radio-${winner}`).click(); + if (isFinal) { + await page.getByTestId("report-score-button").click(); + await submit(page, "confirm-set-end-button"); + } else { + await submit(page, "report-score-button"); + } + } +}; + +export const undoLastReport = (page: Page) => + waitForPOSTResponse(page, async () => { + await page.getByTestId("undo-score-button").click(); + }); diff --git a/e2e/lfg.spec.ts b/e2e/lfg.spec.ts index 17da92adc..ae6d433b5 100644 --- a/e2e/lfg.spec.ts +++ b/e2e/lfg.spec.ts @@ -1,3 +1,4 @@ +import { LFG_PAGE } from "~/utils/urls"; import { expect, impersonate, @@ -5,8 +6,7 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { LFG_PAGE } from "~/utils/urls"; +} from "./helpers/playwright"; test.describe("LFG", () => { test("adds a new lfg post", async ({ page }) => { diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 7ce30e66d..d49d62b40 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -1,4 +1,10 @@ -import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + test, +} from "./helpers/playwright"; test.describe("Navigation", () => { test("desktop navigation", async ({ page }) => { diff --git a/e2e/object-damage-calculator.spec.ts b/e2e/object-damage-calculator.spec.ts index 726560c73..b7157737e 100644 --- a/e2e/object-damage-calculator.spec.ts +++ b/e2e/object-damage-calculator.spec.ts @@ -1,5 +1,5 @@ -import { expect, navigate, selectWeapon, test } from "~/utils/playwright"; import { OBJECT_DAMAGE_CALCULATOR_URL } from "~/utils/urls"; +import { expect, navigate, selectWeapon, test } from "./helpers/playwright"; test.describe("Object Damage Calculator", () => { test.beforeEach(async ({ page }) => { diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 28a472e52..8bcd64630 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -5,6 +5,11 @@ import { newOrganizationSchema, updateIsEstablishedSchema, } from "~/features/tournament-organization/tournament-organization-schemas"; +import { + TOURNAMENT_NEW_PAGE, + tournamentOrganizationPage, + tournamentPage, +} from "~/utils/urls"; import { expect, impersonate, @@ -14,13 +19,8 @@ import { submit, test, waitForPOSTResponse, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; -import { - TOURNAMENT_NEW_PAGE, - tournamentOrganizationPage, - tournamentPage, -} from "~/utils/urls"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; const url = tournamentOrganizationPage({ organizationSlug: "sendouink", diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 561fe7e54..fc6d640db 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,6 +1,7 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { scrimsNewFormSchema } from "~/features/scrims/scrims-schemas"; +import { newScrimPostPage, scrimsPage } from "~/utils/urls"; import { expect, impersonate, @@ -9,9 +10,8 @@ import { selectUser, submit, test, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; -import { newScrimPostPage, scrimsPage } from "~/utils/urls"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; test.describe("Scrims", () => { test("creates a new scrim & deletes it", async ({ page }) => { diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index ef8a147d7..c49d77afe 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 84e45b86b..5a03f2ba9 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index cbf1fbcc4..a7394c984 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 new file mode 100644 index 000000000..d3e4dc534 Binary files /dev/null and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index e200e2f47..28af5c200 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 2aa80b740..81f5afe75 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 52c27d975..1c21aac0d 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 211fbe48b..020894084 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 92388cb5f..2c227202f 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index fdaf1c892..79032609b 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 79ca1fe6b..16b540bd6 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/sendouq-match.spec.ts b/e2e/sendouq-match.spec.ts new file mode 100644 index 000000000..2cf8e05a8 --- /dev/null +++ b/e2e/sendouq-match.spec.ts @@ -0,0 +1,288 @@ +import type { Page } from "@playwright/test"; +import { NZAP_TEST_ID, STAFF_TEST_ID } from "~/db/seed/constants"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PAGE, + sendouQMatchPage, +} from "~/utils/urls"; +import { + expect, + impersonate, + navigate, + seed, + selectWeapon, + test, + waitForPOSTResponse, +} from "./helpers/playwright"; + +/** + * Tests for the SendouQ match page (`/q/match/$id`). + * + * Relies on the `IN_SQ_MATCH` seed variant which puts Sendou (ADMIN) in the + * matchmade group (cascade rejoin vote) and NZAP in the trusted group + * (single-click rejoin). The staff test user (Panda, id 11329) is a + * non-participant staff member that can force-report scores. + * + * Member IDs are deterministic from the seed — Sendou's group members are + * [ADMIN_ID, 95, 96, 97] and NZAP's group members are [NZAP_TEST_ID, 98, 99, 100]. + * + */ + +const ADMIN_GROUP_OTHER_MEMBER_IDS = [95, 96, 97] as const; + +test.describe("SendouQ match page", () => { + test("Score reporting: report, undo, weapon report, confirm", async ({ + page, + }) => { + const matchId = await seedMatchAndGetId(page); + + await reportMapWinner(page, "ALPHA"); + await reportMapWinner(page, "ALPHA"); + + const undoButton = page.getByRole("button", { name: "Undo report" }); + await expect(undoButton).toBeVisible(); + await waitForPOSTResponse(page, async () => { + await undoButton.click(); + }); + + await reportMapWinner(page, "ALPHA"); + + await page.getByRole("button", { name: "Report used weapons" }).click(); + await selectWeapon({ page, name: "Splattershot" }); + await waitForPOSTResponse(page, async () => { + await page + .getByRole("button", { name: "Submit", exact: true }) + .last() + .click(); + }); + await expect( + page.getByRole("button", { name: "Undo weapon" }), + ).toBeVisible(); + + await reportMapWinner(page, "BRAVO"); + await reportMapWinner(page, "ALPHA"); + // Set-ending map (ALPHA's 4th win): confirmation dialog + await selectMapWinner(page, "ALPHA"); + await page + .getByRole("button", { name: "Submit", exact: true }) + .first() + .click(); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + }); + + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Confirm score" }).click(); + }); + + await expect(page.getByText(/4\s*-\s*1/).first()).toBeVisible(); + // Verify the reported Splattershot shows up on the result-tab timeline + // (the compact action-tab timeline omits per-map weapons). + await navigate({ + page, + url: `${sendouQMatchPage(Number(matchId))}?tab=result`, + }); + await expect( + page.getByRole("img", { name: "Splattershot" }).first(), + ).toBeVisible(); + }); + + test("Staff score report: non-participant staff force-reports and locks match", async ({ + page, + }) => { + const matchId = await seedMatchAndGetId(page); + await staffSweepAlpha(page, matchId); + await expect(page.getByText(/4\s*-\s*0/).first()).toBeVisible(); + }); + + test("Cancel flow: request, refused, re-request, accepted locks match", async ({ + page, + }) => { + const matchId = await seedMatchAndGetId(page); + + await page.getByRole("button", { name: "Request cancel" }).click(); + await waitForPOSTResponse(page, async () => { + await page.getByTestId("confirm-button").click(); + }); + await expect( + page.getByText("Pending other team's confirmation"), + ).toBeVisible(); + + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await expect(page.getByText("Accept canceling the set?")).toBeVisible(); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Refuse" }).click(); + }); + + await impersonate(page, ADMIN_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await page.getByRole("button", { name: "Request cancel" }).click(); + await waitForPOSTResponse(page, async () => { + await page.getByTestId("confirm-button").click(); + }); + + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Accept" }).click(); + }); + + await expect(page.getByText("Match canceled")).toBeVisible(); + }); + + test("Rejoin: NZAP trusted group one-click look again", async ({ page }) => { + const matchId = await seedMatchAndGetId(page); + await staffSweepAlpha(page, matchId); + + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await waitForPOSTResponse(page, async () => { + await page + .getByRole("button", { name: "Look again with same group" }) + .click(); + }); + + await navigate({ page, url: SENDOUQ_PAGE }); + await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); + }); + + test("Rejoin vote: 'no' shows rejoin queue link", async ({ page }) => { + const matchId = await seedMatchAndGetId(page); + await staffSweepAlpha(page, matchId); + + await impersonate(page, ADMIN_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await voteNo(page); + + await expect(page.getByText("You declined to continue")).toBeVisible(); + const rejoinLink = page.getByRole("link", { name: "Rejoin queue" }); + await expect(rejoinLink).toHaveAttribute("href", SENDOUQ_PAGE); + }); + + test("Rejoin vote: cascade wipes yes on no, revote completes and rejoins", async ({ + page, + }) => { + const matchId = await seedMatchAndGetId(page); + await staffSweepAlpha(page, matchId); + + await impersonate(page, ADMIN_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Yes, continue" }).click(); + }); + await expect(page.getByLabel("voted yes")).toHaveCount(1); + await expect(page.getByLabel("pending")).toHaveCount(3); + + const [memberB, memberC, memberD] = ADMIN_GROUP_OTHER_MEMBER_IDS; + + await impersonate(page, memberB); + await navigate({ page, url: matchActionUrl(matchId) }); + await voteNo(page); + + await impersonate(page, ADMIN_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + // Sendou's yes was wiped by member B's no → back to pending + await expect(page.getByLabel("voted no")).toHaveCount(1); + await expect(page.getByLabel("voted yes")).toHaveCount(0); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Yes, continue" }).click(); + }); + + for (const memberId of [memberC, memberD]) { + await impersonate(page, memberId); + await navigate({ page, url: matchActionUrl(matchId) }); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Yes, continue" }).click(); + }); + } + + await impersonate(page, ADMIN_ID); + await navigate({ page, url: SENDOUQ_PAGE }); + await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); + const ownGroupCard = page.getByTestId("sendouq-group-card").first(); + await expect( + ownGroupCard.getByTestId("sendouq-group-card-member"), + ).toHaveCount(3); + }); +}); + +function matchActionUrl(matchId: string) { + return `${sendouQMatchPage(Number(matchId))}?tab=action`; +} + +async function seedMatchAndGetId(page: Page) { + await seed(page, "IN_SQ_MATCH"); + await impersonate(page, ADMIN_ID); + await navigate({ page, url: SENDOUQ_PAGE }); + await expect(page).toHaveURL(/\/q\/match\/\d+/); + const matchId = page.url().split("/match/")[1]; + await navigate({ page, url: matchActionUrl(matchId) }); + return matchId; +} + +async function reportMapWinner(page: Page, winner: "ALPHA" | "BRAVO") { + await selectMapWinner(page, winner); + await waitForPOSTResponse(page, async () => { + await page + .getByRole("button", { name: "Submit", exact: true }) + .first() + .click(); + }); + // Wait for the action panel to remount with the new reportedCount. + // waitForPOSTResponse only waits for the POST itself, not the loader + // revalidation. MatchActionTab is keyed on reportedCount, so it (and the + // nested WeaponReporter) unmounts and remounts when the loader returns. + // Without this wait, a follow-up click can land on the about-to-unmount + // instance — local state set by that click (e.g. WeaponReporter's isOpen) + // is then thrown away on remount. + await waitForActionPanelMounted(page); +} + +async function waitForActionPanelMounted(page: Page) { + await expect( + page.locator('[data-testid^="winner-radio-"][data-selected="true"]'), + ).toHaveCount(0); +} + +async function selectMapWinner(page: Page, winner: "ALPHA" | "BRAVO") { + const teamName = winner === "ALPHA" ? "Group Alpha" : "Group Bravo"; + // Wait for the action panel to settle before clicking. waitForPOSTResponse + // only waits for the POST itself; the loader revalidation that swaps in the + // next map's component runs after, so a previous winner can still be + // `data-selected="true"` here. Clicking too early hits the about-to-unmount + // label and the selection is lost on remount. + await expect( + page.locator('[data-testid^="winner-radio-"][data-selected="true"]'), + ).toHaveCount(0); + // react-aria's Radio renders a hidden input behind a span overlay; click the + // wrapping label so the press handler fires and updates winnerId. + await page.locator(`label:has(input[aria-label="${teamName}"])`).click(); +} + +async function voteNo(page: Page) { + await page.getByRole("button", { name: "No, I'm done" }).click(); + await waitForPOSTResponse(page, async () => { + await page.getByTestId("confirm-button").click(); + }); +} + +async function staffSweepAlpha(page: Page, matchId: string) { + await impersonate(page, STAFF_TEST_ID); + await navigate({ page, url: matchActionUrl(matchId) }); + for (let i = 0; i < 3; i++) { + await reportMapWinner(page, "ALPHA"); + } + // 4th ALPHA win triggers the set-ending confirmation dialog + await selectMapWinner(page, "ALPHA"); + await page + .getByRole("button", { name: "Submit", exact: true }) + .first() + .click(); + await waitForPOSTResponse(page, async () => { + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + }); +} diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index 19c88c015..3df01308d 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -1,5 +1,11 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PAGE, + SENDOUQ_PREPARING_PAGE, + sendouQInviteLink, +} from "~/utils/urls"; import { expect, impersonate, @@ -7,15 +13,7 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { - SENDOUQ_LOOKING_PAGE, - SENDOUQ_PAGE, - SENDOUQ_PREPARING_PAGE, - sendouQInviteLink, - sendouQMatchPage, - userSeasonsPage, -} from "~/utils/urls"; +} from "./helpers/playwright"; test.describe("SendouQ", () => { test("Group preparation flow - add friends and users via invite link", async ({ @@ -95,113 +93,6 @@ test.describe("SendouQ", () => { await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); }); - test("Challenge flow - send challenge, report match, seasons page, quick rejoin with replay", async ({ - page, - }) => { - test.slow(); - - await seed(page); // DEFAULT seed includes full groups for ADMIN and NZAP - await impersonate(page, ADMIN_ID); - - // Send challenge - await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); - - // Challenge all available groups (we don't know which is NZAP since full groups are censored) - const groupCards = page.getByTestId("sendouq-group-card"); - const count = await groupCards.count(); - // starting idx 1 to skip own group - for (let i = 1; i < count; i++) { - const groupCard = groupCards.nth(i); - const challengeButton = groupCard - .locator('button[type="submit"]') - .first(); - await challengeButton.click(); - } - - // Accept challenge as NZAP - await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); - - const acceptButton = page - .getByRole("button", { name: "Start match" }) - .first(); - await expect(acceptButton).toBeVisible(); - await acceptButton.click(); - - await expect(page).toHaveURL(/\/q\/match\/\d+/); - const matchId = page.url().split("/match/")[1]; - - // Verify both groups visible - await expect(page.getByText("Alpha", { exact: true })).toBeVisible(); - await expect(page.getByText("Bravo", { exact: true })).toBeVisible(); - - // Report match score (first team - ADMIN) - await impersonate(page, ADMIN_ID); - await navigate({ page, url: sendouQMatchPage(Number(matchId)) }); - - // Report a 4-1 score (ADMIN wins) - const winners = ["BRAVO", "ALPHA", "ALPHA", "ALPHA", "ALPHA"]; - for (let i = 0; i < winners.length; i++) { - const side = winners[i].toLowerCase(); - await page.locator(`#${side}-${i}`).check(); - } - - // Submit score - await submit(page, "submit-score-button"); - - // Report same score as NZAP - await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: sendouQMatchPage(Number(matchId)) }); - - // Report same 4-1 score - for (let i = 0; i < winners.length; i++) { - const side = winners[i].toLowerCase(); - await page.locator(`#${side}-${i}`).check(); - } - - // Submit score and verify match is now locked - await submit(page, "submit-score-button"); - await expect(page.getByText("4 - 1")).toBeVisible(); - - // Verify match on seasons page - await navigate({ - page, - url: userSeasonsPage({ - user: { discordId: "123", customUrl: "sendou" }, - }), - }); - const matchLink = page.locator(`a[href="/q/match/${matchId}"]`); - await expect(matchLink).toBeVisible(); - - // Quick rejoin and replay indicator - // As ADMIN, click "Look again with same group" - await impersonate(page, ADMIN_ID); - await navigate({ page, url: sendouQMatchPage(Number(matchId)) }); - - const lookAgainButton = page.getByRole("button", { - name: "Look again with same group", - }); - - await lookAgainButton.click(); - await page.getByRole("button", { name: "Join the queue" }).click(); - - // Verify redirect to looking page - await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); - - // As NZAP, do the same - await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: sendouQMatchPage(Number(matchId)) }); - - const lookAgainButtonNzap = page.getByRole("button", { - name: "Look again with same group", - }); - - await lookAgainButtonNzap.click(); - await page.getByRole("button", { name: "Join the queue" }).click(); - - await expect(page.getByText("Replay")).toBeVisible(); - }); - test("Request flow - partial groups morph together", async ({ page }) => { await seed(page, "NO_SQ_GROUPS"); @@ -249,39 +140,4 @@ test.describe("SendouQ", () => { combinedGroup.getByTestId("sendouq-group-card-member"), ).toHaveCount(2); }); - - test("Team map preferences shown on match page", async ({ page }) => { - await seed(page, "TEAM_MAP_PREFS"); - await impersonate(page, ADMIN_ID); - - await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); - - const groupCards = page.getByTestId("sendouq-group-card"); - const count = await groupCards.count(); - for (let i = 1; i < count; i++) { - const groupCard = groupCards.nth(i); - const challengeButton = groupCard - .locator('button[type="submit"]') - .first(); - await challengeButton.click(); - } - - await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); - - const acceptButton = page - .getByRole("button", { name: "Start match" }) - .first(); - await expect(acceptButton).toBeVisible(); - await acceptButton.click(); - - await expect(page).toHaveURL(/\/q\/match\/\d+/); - - const popoverButton = page.getByRole("button", { name: /votes/ }).first(); - await expect(popoverButton).toBeVisible(); - await popoverButton.click(); - - const popover = page.getByRole("dialog"); - await expect(popover.getByText("Alliance Rogue")).toBeVisible(); - }); }); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 5ce6c7213..da5bb1746 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -4,6 +4,12 @@ import { disableBuildAbilitySortingSchema, spoilerFreeModeSchema, } from "~/features/settings/settings-schemas"; +import { + CALENDAR_PAGE, + SETTINGS_PAGE, + tournamentBracketsPage, + tournamentResultsPage, +} from "~/utils/urls"; import { expect, impersonate, @@ -12,14 +18,8 @@ import { seed, test, waitForPOSTResponse, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; -import { - CALENDAR_PAGE, - SETTINGS_PAGE, - tournamentBracketsPage, - tournamentResultsPage, -} from "~/utils/urls"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; test.describe("Settings", () => { test("updates 'disableBuildAbilitySorting'", async ({ page }) => { diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index acf4d24c3..d9988f57b 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -1,6 +1,7 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; import { createTeamSchema } from "~/features/team/team-schemas"; +import { editTeamPage, teamPage, userPage } from "~/utils/urls"; import { expect, impersonate, @@ -10,9 +11,8 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; -import { editTeamPage, teamPage, userPage } from "~/utils/urls"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; test.describe("New team creation", () => { test("creates new team", async ({ page }) => { diff --git a/e2e/tier-list-maker.spec.ts b/e2e/tier-list-maker.spec.ts index 7e824ac6f..25e9022af 100644 --- a/e2e/tier-list-maker.spec.ts +++ b/e2e/tier-list-maker.spec.ts @@ -1,6 +1,6 @@ import type { Locator, Page } from "@playwright/test"; -import { expect, navigate, test } from "~/utils/playwright"; import { TIER_LIST_MAKER_URL } from "~/utils/urls"; +import { expect, navigate, test } from "./helpers/playwright"; test.describe("Tier List Maker", () => { test("toggles work, items can be dragged, and state persists after reload", async ({ diff --git a/e2e/top-search.spec.ts b/e2e/top-search.spec.ts index d672d5b56..1ee2ec24e 100644 --- a/e2e/top-search.spec.ts +++ b/e2e/top-search.spec.ts @@ -1,5 +1,5 @@ -import { expect, navigate, seed, test } from "~/utils/playwright"; import { topSearchPage, userPage } from "~/utils/urls"; +import { expect, navigate, seed, test } from "./helpers/playwright"; test.describe("Top search", () => { test("views different x rank placements", async ({ page }) => { diff --git a/e2e/tournament-ab-divisions.spec.ts b/e2e/tournament-ab-divisions.spec.ts index 130f27593..ba8f4eda1 100644 --- a/e2e/tournament-ab-divisions.spec.ts +++ b/e2e/tournament-ab-divisions.spec.ts @@ -1,3 +1,4 @@ +import { tournamentBracketsPage } from "~/utils/urls"; import { expect, impersonate, @@ -5,8 +6,7 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { tournamentBracketsPage } from "~/utils/urls"; +} from "./helpers/playwright"; const AB_RR_TOURNAMENT_ID = 8; const TEAMS_PER_DIVISION = 6; diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 4e8facf19..0859d77e6 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -1,7 +1,16 @@ -import type { Page } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; import { updateNoScreenSchema } from "~/features/settings/settings-schemas"; +import { + NOTIFICATIONS_URL, + SETTINGS_PAGE, + tournamentAdminPage, + tournamentBracketsPage, + tournamentMatchPage, + tournamentPage, + tournamentTeamsPage, + userResultsPage, +} from "~/utils/urls"; import { expect, impersonate, @@ -13,137 +22,16 @@ import { submit, test, waitForPOSTResponse, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; import { - NOTIFICATIONS_URL, - SETTINGS_PAGE, - tournamentAdminPage, - tournamentBracketsPage, - tournamentMatchPage, - tournamentPage, - tournamentTeamsPage, - userResultsPage, -} from "~/utils/urls"; - -const navigateToMatch = async (page: Page, matchId: number) => { - await expect(async () => { - await page.locator(`[data-match-id="${matchId}"]`).click(); - await expect(page.getByTestId("match-header")).toBeVisible(); - }).toPass(); -}; - -const reportResult = async ({ - page, - amountOfMapsToReport, - winner = 1, - points, -}: { - page: Page; - amountOfMapsToReport: 1 | 2 | 3 | 4; - winner?: 1 | 2; - points?: [number, number]; -}) => { - const confirmCheckbox = page.getByTestId("end-confirmation"); - - const fillPointsInput = async () => { - if (!points) return; - await page.getByTestId("points-input-1").fill(String(points[0])); - await page.getByTestId("points-input-2").fill(String(points[1])); - }; - - await page.getByTestId("actions-tab").click(); - - // Auto-detect and set rosters for teams with 5+ players - // Check if first team needs roster selection (checkbox exists and is not disabled) - const firstTeamCheckbox = page.getByTestId("player-checkbox-0").first(); - if ( - (await firstTeamCheckbox.count()) > 0 && - !(await firstTeamCheckbox.isDisabled()) - ) { - await page.getByTestId("player-checkbox-0").first().click(); - await page.getByTestId("player-checkbox-1").first().click(); - await page.getByTestId("player-checkbox-2").first().click(); - await page.getByTestId("player-checkbox-3").first().click(); - - await submit(page, "save-active-roster-button-0"); - - // update went through - await expect(page.getByTestId("player-checkbox-0").first()).toBeDisabled(); - } - - // Check if second team needs roster selection - const lastTeamCheckbox = page.getByTestId("player-checkbox-0").last(); - if ( - (await lastTeamCheckbox.count()) > 0 && - !(await lastTeamCheckbox.isDisabled()) - ) { - await page.getByTestId("player-checkbox-0").last().click(); - await page.getByTestId("player-checkbox-1").last().click(); - await page.getByTestId("player-checkbox-2").last().click(); - await page.getByTestId("player-checkbox-3").last().click(); - - await submit(page, "save-active-roster-button-1"); - } - - await fillPointsInput(); - - await page.getByTestId(`winner-radio-${winner}`).click(); - await submit(page, "report-score-button"); - await expect(page.getByText(winner === 1 ? "1-0" : "0-1")).toBeVisible(); - - if (amountOfMapsToReport >= 2) { - await page.getByTestId(`winner-radio-${winner}`).click(); - await fillPointsInput(); - - if (amountOfMapsToReport === 2) { - await confirmCheckbox.click(); - await submit(page, "report-score-button"); - await expect(page.getByTestId("report-timestamp")).toBeVisible(); - } else { - await submit(page, "report-score-button"); - } - } - - if (amountOfMapsToReport === 3) { - await expect(page.getByText("2-0")).toBeVisible(); - - await page.getByTestId(`winner-radio-${winner}`).click(); - await fillPointsInput(); - - await confirmCheckbox.click(); - await submit(page, "report-score-button"); - - await expect(page.getByTestId("report-timestamp")).toBeVisible(); - } - - if (amountOfMapsToReport === 4) { - await expect(page.getByText("2-0")).toBeVisible(); - - await page.getByTestId(`winner-radio-${winner}`).click(); - await fillPointsInput(); - await submit(page, "report-score-button"); - - await expect(page.getByText("3-0")).toBeVisible(); - - await page.getByTestId(`winner-radio-${winner}`).click(); - - await confirmCheckbox.click(); - await submit(page, "report-score-button"); - - await expect(page.getByTestId("report-timestamp")).toBeVisible(); - } -}; - -const backToBracket = async (page: Page) => { - await expect(async () => { - await page.getByTestId("back-to-bracket-button").click(); - await expect(page.getByTestId("brackets-viewer")).toBeVisible(); - }).toPass(); -}; - -const expectScore = (page: Page, score: [number, number]) => - expect(page.getByText(score.join("-"))).toBeVisible(); + backToBracket, + expectScore, + goToTab, + navigateToMatch, + reportResult, + undoLastReport, +} from "./helpers/tournament-match"; test.describe("Tournament bracket", () => { test("sets active roster as regular member", async ({ page }) => { @@ -161,35 +49,32 @@ test.describe("Tournament bracket", () => { await expect(page.getByTestId("active-roster-needed-text")).toBeVisible(); - await page.getByTestId("actions-tab").click(); - - // Team 10 has 5 players; select first 4 for active roster - // Team 10 is team 2 (second team in the match), so use last() - await page.getByTestId("player-checkbox-0").last().click(); - await page.getByTestId("player-checkbox-1").last().click(); - await page.getByTestId("player-checkbox-2").last().click(); - await page.getByTestId("player-checkbox-3").last().click(); - - await submit(page, "save-active-roster-button-1"); + // Team 10 (5 players) is opponentTwo in match 2 → bravo side. + // The roster tab opens in editing mode by default when active roster is missing. + await goToTab(page, "rosters"); + await page.getByTestId("player-checkbox-bravo-0").click(); + await page.getByTestId("player-checkbox-bravo-1").click(); + await page.getByTestId("player-checkbox-bravo-2").click(); + await page.getByTestId("player-checkbox-bravo-3").click(); + await submit(page, "save-active-roster-button-bravo"); // did it persist? await navigate({ page, url: tournamentMatchPage({ tournamentId, matchId }), }); - // Only team 10 needed to set roster (team 9 has 4 players) await isNotVisible(page.getByTestId("active-roster-needed-text")); - await page.getByTestId("actions-tab").click(); - await page.getByTestId("edit-active-roster-button").click(); - await page.getByTestId("player-checkbox-3").last().click(); - await page.getByTestId("player-checkbox-4").last().click(); - await submit(page, "save-active-roster-button-1"); + await goToTab(page, "rosters"); + await page.getByTestId("edit-active-roster-button-bravo").click(); + // Swap player 3 out for player 4 + await page.getByTestId("player-checkbox-bravo-3").click(); + await page.getByTestId("player-checkbox-bravo-4").click(); + await submit(page, "save-active-roster-button-bravo"); - await expect(page.getByTestId("edit-active-roster-button")).toBeVisible(); await expect( - page.getByTestId("player-checkbox-3").last(), - ).not.toBeChecked(); + page.getByTestId("edit-active-roster-button-bravo"), + ).toBeVisible(); }); // 1) Report winner of N-ZAP's first match @@ -212,7 +97,8 @@ test.describe("Tournament bracket", () => { // 1) await navigateToMatch(page, 5); - await reportResult({ page, amountOfMapsToReport: 2 }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); // 2) @@ -222,30 +108,32 @@ test.describe("Tournament bracket", () => { url: tournamentBracketsPage({ tournamentId }), }); await navigateToMatch(page, 6); - await reportResult({ page, amountOfMapsToReport: 2 }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); // 3) await navigateToMatch(page, 18); - await reportResult({ - page, - amountOfMapsToReport: 1, - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, setEnds: false }); await backToBracket(page); // 4) await navigateToMatch(page, 5); + await goToTab(page, "admin"); await isNotVisible(page.getByTestId("reopen-match-button")); await backToBracket(page); // 5) await navigateToMatch(page, 18); - await submit(page, "undo-score-button"); + await goToTab(page, "action"); + await undoLastReport(page); await expectScore(page, [0, 0]); await backToBracket(page); // 6) await navigateToMatch(page, 5); + await goToTab(page, "admin"); await submit(page, "reopen-match-button"); await expectScore(page, [1, 0]); @@ -256,13 +144,10 @@ test.describe("Tournament bracket", () => { url: tournamentBracketsPage({ tournamentId }), }); await navigateToMatch(page, 5); - await submit(page, "undo-score-button"); + await goToTab(page, "action"); + await undoLastReport(page); await expectScore(page, [0, 0]); - await reportResult({ - page, - amountOfMapsToReport: 2, - winner: 2, - }); + await reportResult(page, { mapsToReport: 2, winner: 2 }); await backToBracket(page); await expect( page.locator("[data-round-id='5'] [data-participant-id='102']"), @@ -335,11 +220,9 @@ test.describe("Tournament bracket", () => { await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="1"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - }); + await navigateToMatch(page, 1); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await page.getByTestId("finalize-tournament-button").click(); @@ -408,11 +291,8 @@ test.describe("Tournament bracket", () => { for (const id of [2, 4, 6, 7, 8, 9, 10, 11, 12]) { await navigateToMatch(page, id); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); } @@ -447,10 +327,8 @@ test.describe("Tournament bracket", () => { await submit(page, "confirm-finalize-bracket-button"); await navigateToMatch(page, 13); - await reportResult({ - page, - amountOfMapsToReport: 3, - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 3 }); await navigate({ page, @@ -460,10 +338,8 @@ test.describe("Tournament bracket", () => { await submit(page, "confirm-finalize-bracket-button"); for (const matchId of [14, 15, 16, 17]) { await navigateToMatch(page, matchId); - await reportResult({ - page, - amountOfMapsToReport: 3, - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 3 }); await backToBracket(page); } @@ -471,8 +347,10 @@ test.describe("Tournament bracket", () => { await page.getByTestId("assign-badges-later-switch").click(); await submit(page, "confirm-button"); - // not possible to reopen finals match anymore + // after finalizing the tournament, the admin tab disappears so the + // reopen action is no longer reachable await navigateToMatch(page, 14); + await isNotVisible(page.getByRole("tab", { name: "Admin" })); await isNotVisible(page.getByTestId("reopen-match-button")); await backToBracket(page); }); @@ -508,23 +386,18 @@ test.describe("Tournament bracket", () => { await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="1"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await navigateToMatch(page, 1); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await page.getByRole("tab", { name: "Great White" }).click(); await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="2"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 3, - }); + await navigateToMatch(page, 2); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 3 }); await backToBracket(page); await page.getByTestId("finalize-tournament-button").click(); @@ -573,12 +446,9 @@ test.describe("Tournament bracket", () => { await submit(page, "confirm-finalize-bracket-button"); for (const matchId of [1, 2, 3, 4, 5, 6]) { - await page.locator(`[data-match-id="${matchId}"]`).click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await navigateToMatch(page, matchId); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); } @@ -591,7 +461,7 @@ test.describe("Tournament bracket", () => { await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="7"]').click(); + await navigateToMatch(page, 7); await expect(page.getByTestId("back-to-bracket-button")).toBeVisible(); await page.getByTestId("admin-tab").click(); @@ -696,25 +566,22 @@ test.describe("Tournament bracket", () => { await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="2"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await navigateToMatch(page, 2); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); - await page.getByTestId("actions-tab").click(); - await page.getByTestId("revise-button").click(); - await page.getByTestId("player-checkbox-3").first().click(); - await page.getByTestId("player-checkbox-4").first().click(); - await page.getByTestId("points-input-1").fill("99"); - await submit(page, "save-revise-button"); + await goToTab(page, "admin"); + await page.getByTestId("edit-result-0-button").click(); + // Swap player 3 out for player 4 on the alpha (winner) team + await page.getByTestId("edit-result-player-checkbox-alpha-3").click(); + await page.getByTestId("edit-result-player-checkbox-alpha-4").click(); + // Toggle KO so we can verify the edit went through (RR collects KO). + await page.getByLabel("KO").check(); + await submit(page, "save-result-0-button"); - await expect(page.getByTestId("revise-button")).toBeVisible(); - await expect( - page.getByTestId("player-checkbox-3").first(), - ).not.toBeChecked(); - await expect(page.getByText("99p")).toBeVisible(); + // Edit returns to read-only view, now showing the KO label + await expect(page.getByTestId("edit-result-0-button")).toBeVisible(); + await expect(page.getByText(/\(KO\)/).first()).toBeVisible(); }); test("changes to picked map pool & best of", async ({ page }) => { @@ -740,8 +607,10 @@ test.describe("Tournament bracket", () => { await page.getByTestId("increase-map-count-button").first().click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="1"]').click(); - await expect(page.getByTestId("mode-progress-CB")).toHaveCount(5); + await navigateToMatch(page, 1); + // Bo5 of clam blitz: one mode icon + ×5 count text + await expect(page.getByTestId("mode-progress-CB")).toBeVisible(); + await expect(page.getByText("×5")).toBeVisible(); }); test("reopens round robin match and changes score", async ({ page }) => { @@ -761,47 +630,36 @@ test.describe("Tournament bracket", () => { // needs also to be completed so 9 unlocks await navigateToMatch(page, 7); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); // set situation where match A is completed and its participants also completed their follow up matches B & C // and then we go back and change the winner of A await navigateToMatch(page, 8); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await navigateToMatch(page, 9); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await navigateToMatch(page, 10); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await navigateToMatch(page, 8); + await goToTab(page, "admin"); await submit(page, "reopen-match-button"); - await submit(page, "undo-score-button"); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [0, 100], + await goToTab(page, "action"); + await undoLastReport(page); + await reportResult(page, { + mapsToReport: 2, winner: 2, + setEnds: true, }); }); @@ -827,41 +685,33 @@ test.describe("Tournament bracket", () => { // Complete R1 matches in group B (matches 7 and 8) to unlock R2 matches await navigateToMatch(page, 7); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await navigateToMatch(page, 8); - await reportResult({ - page, - amountOfMapsToReport: 2, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); // Match 9 is R2 in group B - should now be unlocked since R1 is complete // Start it but don't complete it await navigateToMatch(page, 9); - await reportResult({ - page, - amountOfMapsToReport: 1, - points: [100, 0], - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, setEnds: false }); await backToBracket(page); // Reopen match 7 (R1 match) - simulating a score misreport correction await navigateToMatch(page, 7); + await goToTab(page, "admin"); await submit(page, "reopen-match-button"); await backToBracket(page); // Verify the R2 match that was already in progress is still playable // Before the fix, this would become locked and unplayable await navigateToMatch(page, 9); - await expect(page.getByText("1-0")).toBeVisible(); - await page.getByTestId("actions-tab").click(); + await expectScore(page, [1, 0]); + await goToTab(page, "action"); await expect(page.getByTestId("winner-radio-1")).toBeVisible(); }); @@ -898,32 +748,35 @@ test.describe("Tournament bracket", () => { await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="1"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - }); + await navigateToMatch(page, 1); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); - await page.locator('[data-match-id="3"]').click(); + await navigateToMatch(page, 3); + await goToTab(page, "admin"); + // Picking a chip auto-submits the cast channel; lock the match afterwards. + await waitForPOSTResponse(page, async () => { + await page.locator('label[for$="-test"]').click(); + }); await submit(page, "cast-info-submit-button"); await backToBracket(page); - await page.locator('[data-match-id="2"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - }); + await navigateToMatch(page, 2); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); await expect(page.getByText("🔒 CAST")).toBeVisible(); - await page.locator('[data-match-id="3"]').click(); - await expect(page.getByText("Match locked to be casted")).toBeVisible(); + await navigateToMatch(page, 3); + await goToTab(page, "admin"); + // Lock state is signalled by the toggle being "Unlock" instead of "Lock" + await expect(page.getByRole("button", { name: "Unlock" })).toBeVisible(); await submit(page, "cast-info-submit-button"); await expect(page.getByTestId("stage-banner")).toBeVisible(); - await page.getByTestId("cast-info-select").selectOption("test"); - await submit(page, "cast-info-submit-button"); + // Cast channel "test" persists across unlock; the bracket badge flips + // from 🔒 CAST to 🔴 LIVE once the match is unlocked and ongoing. await backToBracket(page); await expect(page.getByText("🔴 LIVE")).toBeVisible(); }); @@ -943,11 +796,9 @@ test.describe("Tournament bracket", () => { await submit(page, "confirm-finalize-bracket-button"); await isNotVisible(page.locator('[data-match-id="1"]')); - await page.locator('[data-match-id="2"]').click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - }); + await navigateToMatch(page, 2); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await page.getByTestId("admin-tab").click(); await page @@ -988,11 +839,11 @@ test.describe("Tournament bracket", () => { await page.getByTestId("finalize-bracket-button").click(); await submit(page, "confirm-finalize-bracket-button"); - await page.locator('[data-match-id="1"]').click(); + await navigateToMatch(page, 1); await expect(page.getByTestId("screen-banned")).toBeVisible(); await backToBracket(page); - await page.locator('[data-match-id="2"]').click(); + await navigateToMatch(page, 2); await expect(page.getByTestId("screen-allowed")).toBeVisible(); }); @@ -1015,12 +866,8 @@ test.describe("Tournament bracket", () => { await navigateToMatch(page, 1); await expect(page.getByText("Play all 3")).toBeVisible(); - await reportResult({ - page, - amountOfMapsToReport: 3, - points: [100, 0], - winner: 1, - }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 3 }); }); test("swiss tournament with bracket advancing/unadvancing & dropping out a team", async ({ @@ -1043,11 +890,9 @@ test.describe("Tournament bracket", () => { // report all group A round 1 scores for (const id of [1, 2, 3, 4]) { - await page.locator(`[data-match-id="${id}"]`).click(); - await reportResult({ - page, - amountOfMapsToReport: 2, - }); + await navigateToMatch(page, id); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); } @@ -1171,13 +1016,15 @@ test.describe("Tournament bracket", () => { page, url: tournamentMatchPage({ tournamentId, matchId }), }); - await page.getByTestId("actions-tab").click(); + await goToTab(page, "action"); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); } - await expect(page.getByTestId("mode-progress-banned")).toHaveCount(2); + // once both teams banned the ban prompt is gone and the actual map + // banner takes over. + await expect(page.getByTestId("stage-banner")).toBeVisible(); } await impersonate(page, teamOneCaptainId); @@ -1187,14 +1034,12 @@ test.describe("Tournament bracket", () => { url: tournamentMatchPage({ tournamentId, matchId }), }); - await page.getByTestId("actions-tab").click(); - await page.getByTestId("winner-radio-2").click(); - await page.getByTestId("points-input-2").fill("100"); - await submit(page, "report-score-button"); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, winner: 2, setEnds: false }); if (pickBan === "COUNTERPICK") { await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); } await impersonate(page, teamTwoCaptainId); @@ -1204,24 +1049,22 @@ test.describe("Tournament bracket", () => { url: tournamentMatchPage({ tournamentId, matchId }), }); - await page.getByTestId("actions-tab").click(); - await page.getByTestId("winner-radio-1").click(); - await page.getByTestId("points-input-1").fill("100"); - await submit(page, "report-score-button"); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, winner: 1, setEnds: false }); if (pickBan === "COUNTERPICK") { await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); - await submit(page, "undo-score-button"); - await expect( - page.getByText("Please select the winner of this map"), - ).toBeVisible(); - await page.getByTestId("winner-radio-1").click(); - await page.getByTestId("points-input-1").fill("100"); - await submit(page, "report-score-button"); + await undoLastReport(page); + await expect(page.getByText("Select the winner")).toBeVisible(); + await reportResult(page, { + mapsToReport: 1, + winner: 1, + setEnds: false, + }); await page.getByTestId("pick-ban-button").last().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); await expect( page.getByText("Counterpick", { exact: true }), ).toBeVisible(); @@ -1241,7 +1084,8 @@ test.describe("Tournament bracket", () => { await page.clock.install({ time: new Date() }); - await reportResult({ page, amountOfMapsToReport: 1, winner: 1 }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, winner: 1, setEnds: false }); await expect(page.getByTestId("match-timer")).toBeVisible(); @@ -1260,12 +1104,13 @@ test.describe("Tournament bracket", () => { await navigateToMatch(page, matchId); - await page.getByText("End Set").click(); + await goToTab(page, "admin"); + await page.getByRole("button", { name: "End set" }).click(); await page.getByRole("radio", { name: /Random/ }).check(); await submit(page, "end-set-button"); - // Verify match ended early - await expect(page.getByText("Match ended early")).toBeVisible(); + // Match is now finalized (no longer ongoing) → "Final" appears in banner + await expect(page.getByTestId("match-final")).toBeVisible(); }); test("dropping team out ends ongoing match early and auto-forfeits losers bracket match", async ({ @@ -1277,7 +1122,8 @@ test.describe("Tournament bracket", () => { // 1) Report partial score on match 5 (winners bracket) await navigateToMatch(page, 5); - await reportResult({ page, amountOfMapsToReport: 1, winner: 1 }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, winner: 1, setEnds: false }); await backToBracket(page); // 2) Drop team 102 (one of the teams in match 5) via admin @@ -1289,18 +1135,18 @@ test.describe("Tournament bracket", () => { await page.getByLabel("Team", { exact: true }).selectOption("102"); await submit(page); - // 3) Verify the ongoing match ended early + // 3) Verify the ongoing match ended early (no longer ongoing → "Final") await navigate({ page, url: tournamentMatchPage({ tournamentId, matchId: 5 }), }); - await expect(page.getByText("Match ended early")).toBeVisible(); - await expect(page.getByText("dropped out of the tournament")).toBeVisible(); + await expect(page.getByTestId("match-final")).toBeVisible(); await backToBracket(page); // 4) Complete the adjacent match (match 6) so its loser goes to losers bracket await navigateToMatch(page, 6); - await reportResult({ page, amountOfMapsToReport: 2 }); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 2 }); await backToBracket(page); // 5) The losers bracket match (match 18) should now have teams: @@ -1308,8 +1154,7 @@ test.describe("Tournament bracket", () => { // - Loser of match 6 // It should have ended early since team 102 is dropped await navigateToMatch(page, 18); - await expect(page.getByText("Match ended early")).toBeVisible(); - await expect(page.getByText("dropped out of the tournament")).toBeVisible(); + await expect(page.getByTestId("match-final")).toBeVisible(); }); test("ban/pick CUSTOM flow", async ({ page }) => { @@ -1378,14 +1223,14 @@ test.describe("Tournament bracket", () => { page, url: tournamentMatchPage({ tournamentId, matchId }), }); - await page.getByTestId("actions-tab").click(); + await goToTab(page, "action"); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); await expect(page.getByText(/Ban a map \(2\/2\)/)).toBeVisible(); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); // 3) PreSet: Lower seed bans 2 maps await impersonate(page, lowerSeedCaptainId); @@ -1393,32 +1238,30 @@ test.describe("Tournament bracket", () => { page, url: tournamentMatchPage({ tournamentId, matchId }), }); - await page.getByTestId("actions-tab").click(); + await goToTab(page, "action"); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); await expect(page.getByText(/Ban a map \(2\/2\)/)).toBeVisible(); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); // 4) Roll auto-executed after last ban; report game 1 score await expect(page.getByTestId("stage-banner")).toBeVisible(); - await page.getByTestId("actions-tab").click(); + await goToTab(page, "action"); - await page.getByTestId("winner-radio-1").click(); - await page.getByTestId("points-input-1").fill("100"); - await submit(page, "report-score-button"); + await reportResult(page, { mapsToReport: 1, winner: 1, setEnds: false }); await expectScore(page, [1, 0]); // 5) PostGame: Winner (team 1, captain 33) bans 2 maps await expect(page.getByText(/Ban a map/)).toBeVisible(); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); await expect(page.getByText(/Ban a map \(2\/2\)/)).toBeVisible(); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); // PostGame: Loser (team 2, captain 29) picks a map await impersonate(page, higherSeedCaptainId); @@ -1426,24 +1269,22 @@ test.describe("Tournament bracket", () => { page, url: tournamentMatchPage({ tournamentId, matchId }), }); - await page.getByTestId("actions-tab").click(); + await goToTab(page, "action"); await expect(page.getByText(/Pick a map/)).toBeVisible(); await page.getByTestId("pick-ban-button").first().click(); - await submit(page); + await submit(page, "pick-ban-submit-button"); // 6) Undo game 1 score — also deletes postGame pick/ban events await expect(page.getByTestId("stage-banner")).toBeVisible(); - await submit(page, "undo-score-button"); + await undoLastReport(page); await expectScore(page, [0, 0]); await expect(page.getByTestId("stage-banner")).toBeVisible(); // 7) Re-report game 1 and verify postGame cycle restarts - await page.getByTestId("actions-tab").click(); - await page.getByTestId("winner-radio-1").click(); - await page.getByTestId("points-input-1").fill("100"); - await submit(page, "report-score-button"); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, winner: 1, setEnds: false }); await expectScore(page, [1, 0]); await expect(page.getByText(/Ban a map/)).toBeVisible(); diff --git a/e2e/tournament-staff.spec.ts b/e2e/tournament-staff.spec.ts index 59b58ae8d..f36946b3a 100644 --- a/e2e/tournament-staff.spec.ts +++ b/e2e/tournament-staff.spec.ts @@ -1,5 +1,10 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { + tournamentAdminPage, + tournamentBracketsPage, + tournamentMatchPage, +} from "~/utils/urls"; import { expect, impersonate, @@ -11,12 +16,7 @@ import { startBracket, submit, test, -} from "~/utils/playwright"; -import { - tournamentAdminPage, - tournamentBracketsPage, - tournamentMatchPage, -} from "~/utils/urls"; +} from "./helpers/playwright"; const TOURNAMENT_ID = 2; diff --git a/e2e/tournament-streams.spec.ts b/e2e/tournament-streams.spec.ts index 7ef2e4b24..1ee23f59f 100644 --- a/e2e/tournament-streams.spec.ts +++ b/e2e/tournament-streams.spec.ts @@ -1,4 +1,8 @@ -import type { Page } from "@playwright/test"; +import { + tournamentAdminPage, + tournamentBracketsPage, + tournamentStreamsPage, +} from "~/utils/urls"; import { expect, impersonate, @@ -7,49 +11,13 @@ import { startBracket, submit, test, -} from "~/utils/playwright"; +} from "./helpers/playwright"; import { - tournamentAdminPage, - tournamentBracketsPage, - tournamentStreamsPage, -} from "~/utils/urls"; - -const navigateToMatch = async (page: Page, matchId: number) => { - await expect(async () => { - await page.locator(`[data-match-id="${matchId}"]`).click(); - await expect(page.getByTestId("match-header")).toBeVisible(); - }).toPass(); -}; - -const selectRosterIfNeeded = async (page: Page, teamIndex: 0 | 1) => { - const position = teamIndex === 0 ? "first" : "last"; - const checkbox = page.getByTestId("player-checkbox-0")[position](); - - if ((await checkbox.count()) > 0 && !(await checkbox.isDisabled())) { - await page.getByTestId("player-checkbox-0")[position]().click(); - await page.getByTestId("player-checkbox-1")[position]().click(); - await page.getByTestId("player-checkbox-2")[position]().click(); - await page.getByTestId("player-checkbox-3")[position]().click(); - await submit(page, `save-active-roster-button-${teamIndex}`); - await expect( - page.getByTestId("player-checkbox-0")[position](), - ).toBeDisabled(); - } -}; - -const reportPartialScore = async (page: Page) => { - await page.getByTestId("actions-tab").click(); - await selectRosterIfNeeded(page, 0); - await selectRosterIfNeeded(page, 1); - await page.getByTestId("winner-radio-1").click(); - await submit(page, "report-score-button"); - await expect(page.getByText("1-0")).toBeVisible(); -}; - -const backToBracket = async (page: Page) => { - await page.getByTestId("back-to-bracket-button").click(); - await expect(page.getByTestId("brackets-viewer")).toBeVisible(); -}; + backToBracket, + goToTab, + navigateToMatch, + reportResult, +} from "./helpers/tournament-match"; test.describe("Tournament streams", () => { test("can set cast twitch accounts in admin", async ({ page }) => { @@ -95,7 +63,8 @@ test.describe("Tournament streams", () => { await navigateToMatch(page, matchId); // Report partial score to set startedAt (match becomes "in progress") - await reportPartialScore(page); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, setEnds: false }); await backToBracket(page); // The LIVE button should be visible since team 102 members are streaming @@ -158,11 +127,12 @@ test.describe("Tournament streams", () => { // Navigate to match and start it await navigateToMatch(page, matchId); - await reportPartialScore(page); + await goToTab(page, "action"); + await reportResult(page, { mapsToReport: 1, setEnds: false }); - // Set match as casted - await page.getByTestId("cast-info-select").selectOption("test_cast_stream"); - await submit(page, "cast-info-submit-button"); + // Set match as casted via chip radio + await goToTab(page, "admin"); + await page.locator('label[for$="-test_cast_stream"]').click(); await backToBracket(page); // Verify LIVE button appears (multiple may exist from player streams) diff --git a/e2e/tournament-tiers.spec.ts b/e2e/tournament-tiers.spec.ts index d44f60581..633055417 100644 --- a/e2e/tournament-tiers.spec.ts +++ b/e2e/tournament-tiers.spec.ts @@ -1,3 +1,4 @@ +import { calendarPage, tournamentBracketsPage } from "~/utils/urls"; import { expect, impersonate, @@ -5,8 +6,7 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { calendarPage, tournamentBracketsPage } from "~/utils/urls"; +} from "./helpers/playwright"; test.describe("Tournament tiers", () => { test("shows tentative tier before bracket starts and confirmed tier after", async ({ diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index 1494c346e..e3c002562 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -1,6 +1,11 @@ import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { StageId } from "~/modules/in-game-lists/types"; +import { + tournamentBracketsPage, + tournamentPage, + tournamentTeamsPage, +} from "~/utils/urls"; import { expect, impersonate, @@ -9,12 +14,7 @@ import { seed, submit, test, -} from "~/utils/playwright"; -import { - tournamentBracketsPage, - tournamentPage, - tournamentTeamsPage, -} from "~/utils/urls"; +} from "./helpers/playwright"; // TODO: restore operates admin controls after single fetch tested in prod diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index f6c9bec9b..7b396402f 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; import { userEditProfileBaseSchema } from "~/features/user-page/user-page-schemas"; +import { userEditProfilePage, userPage } from "~/utils/urls"; import { expect, impersonate, @@ -11,9 +12,8 @@ import { submit, test, waitForPOSTResponse, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; -import { userEditProfilePage, userPage } from "~/utils/urls"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; const goToEditPage = (page: Page) => page.getByText("Edit", { exact: true }).click(); diff --git a/e2e/vods.spec.ts b/e2e/vods.spec.ts index 4cdfd6dc2..c4651157a 100644 --- a/e2e/vods.spec.ts +++ b/e2e/vods.spec.ts @@ -1,4 +1,5 @@ import { vodFormBaseSchema } from "~/features/vods/vods-schemas"; +import { newVodPage, VODS_PAGE, vodVideoPage } from "~/utils/urls"; import { expect, impersonate, @@ -10,9 +11,8 @@ import { selectWeapon, submit, test, -} from "~/utils/playwright"; -import { createFormHelpers } from "~/utils/playwright-form"; -import { newVodPage, VODS_PAGE, vodVideoPage } from "~/utils/urls"; +} from "./helpers/playwright"; +import { createFormHelpers } from "./helpers/playwright-form"; const VIDEO_DATE = new Date(2024, 4, 15, 12, 0); // May 15, 2024 at 12:00 diff --git a/locales/da/art.json b/locales/da/art.json index cc15c1e11..92c5a3710 100644 --- a/locales/da/art.json +++ b/locales/da/art.json @@ -9,7 +9,6 @@ "filteringByTag": "Viser resultater filtreret efter #{{tag}}", "commissionsOpen": "Åben for bestillinger", "commissionsClosed": "Lukket for bestillinger", - "openCommissionsOnly": "Vis kunstnere med åbne bestillinger", "gainPerms": "Lav venligt et opslad til vores helpdesk på vores Discord-server for at få tilladelse til at uploade kunst. Bemærk venligt, at du skal være kunstneren af det kunst, som du uploader, og kun Splatoon-relateret kunst tillades.", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/da/common.json b/locales/da/common.json index ae76e065d..ab824fe9d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -203,8 +204,6 @@ "tag.name.COUNT": "Tilmeldingsloft", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Kvalifikationsturnering", - "tag.name.SZ": "Kun DD (SZ)", - "tag.name.TW": "RK (TW) Inkluderet", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -319,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -392,6 +392,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index f765ac44f..5cfe6d1c1 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Note: Hvis du ændrer dit holds navn, så kan andre hold overtage det tidligere holdnavn og URL-adresse.", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/da/q.json b/locales/da/q.json index ff80aa05d..d751dcf8c 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/da/tournament.json b/locales/da/tournament.json index a32c9c617..fea208dc3 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Hold {{number}} banevalg", "pickInfo.team.specific": "{{team}} valgte", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Tiebreak", "pickInfo.both": "begge hold valgte", "pickInfo.default": "", @@ -111,6 +113,15 @@ "match.pool": "Pulje", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best ud af {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Spil alle runder {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Annuller sidste score", "match.action.reopenMatch": "Genåbn kamp", "match.action.endSet": "", @@ -118,6 +129,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Invitationskoden mangler. Blev hele linket kopieret?", "join.error.SHORT_CODE": "Invitationskoden har ikke den korrekte længde. Blev hele linket kopieret?", "join.error.NO_TEAM_MATCHING_CODE": "Ingen hold passer til denne invitationskode", @@ -156,6 +179,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -187,9 +211,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/de/art.json b/locales/de/art.json index d75450428..26d9f7108 100644 --- a/locales/de/art.json +++ b/locales/de/art.json @@ -9,7 +9,6 @@ "filteringByTag": "", "commissionsOpen": "", "commissionsClosed": "", - "openCommissionsOnly": "", "gainPerms": "", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/de/common.json b/locales/de/common.json index df000ce93..4e1058014 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -203,8 +204,6 @@ "tag.name.COUNT": "Zulassungsgrenze", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualifikation", - "tag.name.SZ": "Nur Herrschaft", - "tag.name.TW": "", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -319,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -392,6 +392,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 3e8616cab..bec561d9e 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Hinweis: Wenn du den Namen deines Teams änderst, können andere Teams den Namen und und die URL für sich beanspruchen.", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/de/q.json b/locales/de/q.json index 3355040aa..1728323c7 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 0c2c191da..4524dfcaa 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "Name", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 40ef0e573..9dbb5d37f 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Team {{number}} Auswahl", "pickInfo.team.specific": "Von {{team}} ausgewählt", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Tiebreaker", "pickInfo.both": "Von beiden ausgewählt", "pickInfo.default": "", @@ -111,6 +113,15 @@ "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Letztes Ergebnis widerrufen", "match.action.reopenMatch": "Match erneut öffnen", "match.action.endSet": "", @@ -118,6 +129,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Invite-Code fehlt. Wurde die vollständige URL kopiert?", "join.error.SHORT_CODE": "Invite-Code hat nicht die richtige Länge. Wurde die vollständige URL kopiert?", "join.error.NO_TEAM_MATCHING_CODE": "Kein Team mit diesem Invite-Code gefunden.", @@ -156,6 +179,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -187,9 +211,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/en/art.json b/locales/en/art.json index 785c750b9..9afa33411 100644 --- a/locales/en/art.json +++ b/locales/en/art.json @@ -9,7 +9,6 @@ "filteringByTag": "Showing results filtered by #{{tag}}", "commissionsOpen": "Commissions are open", "commissionsClosed": "Commissions are closed", - "openCommissionsOnly": "Show artists with open commissions", "gainPerms": "Please post on the helpdesk of our Discord to gain permissions to upload art. Note that you must be the artist of the art you are uploading and only Splatoon related art is allowed.", "tabs.recentlyUploaded": "Recently Uploaded", "tabs.showcase": "Showcase", diff --git a/locales/en/common.json b/locales/en/common.json index aec67cec0..c9d8b26df 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -79,11 +79,11 @@ "notifications.title.SCRIM_NEW_REQUEST": "New Scrim Request", "notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} requested a scrim", "notifications.title.SCRIM_SCHEDULED": "Scrim Scheduled", - "notifications.text.SCRIM_SCHEDULED": "New scrim scheduled at {{timeString}}", + "notifications.text.SCRIM_SCHEDULED": "New scrim scheduled vs. {{opponentTeamName}}", "notifications.title.SCRIM_CANCELED": "Scrim Canceled", - "notifications.text.SCRIM_CANCELED": "The scrim at {{timeString}} was canceled", + "notifications.text.SCRIM_CANCELED": "The scrim vs. {{opponentTeamName}} was canceled", "notifications.title.SCRIM_STARTING_SOON": "Scrim Starting Soon", - "notifications.text.SCRIM_STARTING_SOON": "Your scrim at {{timeString}} is starting soon", + "notifications.text.SCRIM_STARTING_SOON": "Your scrim vs. {{opponentTeamName}} is starting soon", "notifications.title.COMMISSIONS_CLOSED": "Commissions Closed", "notifications.text.COMMISSIONS_CLOSED": "If your commissions are still open, please re-enable them", "notifications.title.FRIEND_REQUEST_RECEIVED": "Friend Request", @@ -143,6 +143,7 @@ "actions.disable": "Disable", "actions.accept": "Accept", "actions.decline": "Decline", + "actions.refuse": "Refuse", "actions.confirm": "Confirm", "actions.next": "Next", "actions.previous": "Previous", @@ -203,8 +204,6 @@ "tag.name.COUNT": "Entry limit", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualifier", - "tag.name.SZ": "SZ Only", - "tag.name.TW": "Includes TW", "tag.name.ONES": "1v1", "tag.name.DUOS": "2v2", "tag.name.TRIOS": "3v3", @@ -319,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} confirmed score. Match is now locked", "chat.systemMsg.cancelReported": "{{name}} requested canceling the match", "chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked", + "chat.systemMsg.cancelRefused": "{{name}} refused canceling the match", "chat.systemMsg.userLeft": "{{name}} left the group", "chat.newMessages": "New messages", "chat.sidebar.title": "Chat", @@ -392,6 +392,7 @@ "search.type.weapons": "Weapons", "search.noResults": "No results found", "search.hint": "Start typing to search", + "search.searching": "Searching...", "dataCredit.lean": "Data credit: Lean", "header.parameter": "Parameter", "weaponArt.title": "Community Art", diff --git a/locales/en/forms.json b/locales/en/forms.json index ff38a26a9..e1186c98a 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -5,9 +5,11 @@ "labels.tag": "Tag", "labels.teamBsky": "Team Bluesky", "labels.clockFormat": "Clock format", + "labels.dateFormat": "Date format", "labels.disableBuildAbilitySorting": "Builds: Disable automatic ability sorting", "labels.disallowScrimPickupsFromUntrusted": "Disallow scrim pickups from non-friends", "labels.noScreen": "[Accessibility] Avoid Splattercolor Screen", + "labels.noSplatnet": "No SplatNet access", "labels.spoilerFreeMode": "Spoiler-free mode", "bottomTexts.name": "Note that if you change your team's name then someone else can claim the name and URL for their team", "bottomTexts.tag": "Typically used before in-game name to indicate membership of a team (e.g. [TAG] PlayerName)", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "Automatic", "options.clockFormat.24h": "24-hour", "options.clockFormat.12h": "12-hour", + "options.dateFormat.auto": "Automatic", + "options.dateFormat.MDY": "MM/DD/YYYY", + "options.dateFormat.DMY": "DD/MM/YYYY", + "options.dateFormat.YMD": "YYYY-MM-DD", "errors.required": "This field is required", "errors.minLength": "Must be at least {{min}} characters", "errors.maxLength": "Must be at most {{max}} characters", @@ -154,8 +160,6 @@ "options.tag.LAN": "LAN", "options.tag.QUALIFIER": "Qualifier", "options.tag.COLLEGIATE": "Collegiate", - "options.tag.SZ": "SZ Only", - "options.tag.TW": "Includes TW", "options.tag.ONES": "1v1", "options.tag.DUOS": "2v2", "options.tag.TRIOS": "3v3", diff --git a/locales/en/q.json b/locales/en/q.json index 76316120d..4d3b5834e 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Leave group", "looking.groups.actions.stopLooking": "Stop looking", "looking.groups.actions.stopLooking.confirm": "Do you want to stop looking (any invitations given/received deleted)?", + "looking.groups.actions.organizerRemove.confirm": "Remove {{name}} from LFG?", "looking.groups.actions.leaveQ": "Leave queue", "looking.groups.actions.goBack": "Go back", "looking.groups.adder.quickAdd": "Quick add", @@ -124,6 +125,7 @@ "looking.range.or": "or", "looking.range.or.explanation": "You will be matched with a group whose average rank is one of these. The exact rank is not shown to avoid behavior where groups only play against tiers lower than themselves.", "match.header": "Match #{{number}}", + "match.mapVoters.header": "Voted for this map", "match.spInfo": "SP will be adjusted after both teams report the same result", "match.dispute.button": "Dispute?", "match.dispute.p1": "If there is a mistake, contact the other team to correct it on their side. Score can be freely rereported till both teams report the same result.", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Weapon", "match.sides.alpha": "Alpha", "match.sides.bravo": "Bravo", + "match.groupAlpha": "Group Alpha", + "match.groupBravo": "Group Bravo", "match.helpdesk": "Helpdesk", "match.pool": "Pool", "match.password.short": "Pass", + "match.hostedBy": "Hosted by", + "match.noSplatnetWarning": "One or more players don't have Splatnet, remember to use the pool and pass", + "match.room.noRoomHint": "Send the SplatNet room link to the chat or use the Share feature in the SplatNet3 app", + "match.room.stalePrompt": "Your room from {{minutes}} minutes ago still up?", + "match.room.confirm": "Confirm", "match.cancelMatch": "Cancel match", "match.canceled": "Match canceled", + "match.canceled.detail": "Set was canceled as requested by {{requester}} and accepted by {{accepter}}", "match.cancelRequested": "Cancel requested", "match.reportedBy": "Reported by {{name}} at", "match.cancelPendingConfirmation": "Pending other team's confirmation", "match.cancelMatch.confirm": "Cancel match? (requires confirmation from the other group, abuse of the feature will lead to a ban)", + "match.adminCancel.confirm": "Cancel this match as admin?", "match.tabs.reportScore": "Report score", "match.errors.cantCancel": "Can't cancel since opponent has already reported score for this match. See dispute instructions at the top of the page.", "match.errors.different": "You reported different results than your opponent. Double check the above is correct and otherwise see dispute instructions at the top of the page.", @@ -165,6 +176,56 @@ "match.outcome.loss": "loss", "match.screen.ban": "Weapons with {{special}} are not allowed in this match", "match.screen.allowed": "Weapons with {{special}} are allowed in this match", + "match.subbedOut": "Subbed out", + "match.action.selectWinner": "Select the winner", + "match.action.myTeam": "My team", + "match.action.opponent": "Opponent", + "match.action.ko": "KO", + "match.action.confirmSetEnding": "This score will end the set. Please confirm below.", + "match.action.requestCancel": "Request cancel", + "match.action.adminCancel": "Admin cancel", + "match.action.acceptCancelingSet": "Accept canceling the set?", + "match.action.pickStage": "Pick a stage", + "match.action.banStage": "Ban a stage", + "match.action.pickMode": "Pick a mode", + "match.action.banMode": "Ban a mode", + "match.action.pickerUs": "Us", + "match.action.pickerThem": "Them", + "match.action.pickerBoth": "Both", + "match.action.pickBanPrompt": "Make your selection above", + "match.action.pickBanWaiting": "Waiting for captain of {{teamName}} to make their selection", + "match.action.picking": "Picking", + "match.action.banning": "Banning", + "match.cancelRequested.subtitle": "{{teamName}} has requested to cancel the set", + "match.timeline.win": "Win", + "match.timeline.loss": "Loss", + "match.timeline.out": "Out", + "match.timeline.in": "In", + "match.timeline.live": "LIVE", + "match.timeline.picked": "Picked", + "match.waitingForConfirmation": "Waiting for the other team to confirm the result", + "match.undoReport": "Undo report", + "match.weapon.yourWeapon": "Your weapon", + "match.weapon.undoWeapon": "Undo weapon", + "match.confirmScore": "Confirm score", + "match.confirmScore.wrongHint": "Wrong score? Ask the other team to undo their report and adjust it.", + "match.rematch.prompt": "Continue queueing with the group of {{count}}?", + "match.rematch.resolved": "New group of {{count}} formed", + "match.rematch.vote.yes": "Yes, continue", + "match.rematch.vote.no": "No, I'm done", + "match.rematch.vote.noConfirm": "Vote no? You can't change your vote afterwards.", + "match.rematch.declined": "You declined to continue", + "match.rematch.fizzled": "Nobody wanted to continue", + "match.rematch.waitingCaptain": "Waiting for the captain to choose whether to re-queue", + "match.rematch.rejoinQueue": "Rejoin queue", + "match.rematch.backToQueue": "Back to queue", + "match.banner.final": "Final", + "match.banner.bestOf": "Best of {{count}}", + "match.banner.playAll": "Play all {{count}}", + "match.banner.vs": "vs.", + "match.tabs.rosters": "Rosters", + "match.tabs.action": "Action", + "match.tabs.result": "Result", "preparing.joinQ": "Join the queue", "tiers.currentCriteria": "Current criteria", "tiers.info.p1": "For example, Leviathan is the top 5% of players. Diamond is the 85th percentile etc.", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index c35b2fdaf..c69545d48 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "Name", "forms.managedByAnyone.title": "Anyone can manage", "forms.managedByAnyone.explanation": "If enabled, all users in this post can accept requests and delete the post, not just the owner.", - "alert.canceled": "This scrim was canceled by {{user}}. Reason: {{reason}}", - "maps.header": "Maps", - "screenBan.header": "Screen", - "screenBan.warning": "Ask before playing Splattercolor Screen", - "screenBan.allowed": "Nobody in this scrim has indicated they want to avoid Splattercolor Screen" + "banner.canceled.header": "Canceled by {{user}}", + "banner.canceled.subtitle": "Reason: {{reason}}", + "banner.freeForm.header": "Free form practice", + "banner.freeForm.subtitle": "Communicate the maplist with the opponents" } diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 8e425c287..f4f1876b9 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Team {{number}} pick", "pickInfo.team.specific": "{{team}} picked", "pickInfo.team.counterpick": "{{team}} counterpicked", + "pickInfo.pickedBy": "Picked by {{teamName}}", + "pickInfo.counterpickedBy": "Counterpicked by {{teamName}}", "pickInfo.tiebreaker": "Tiebreaker", "pickInfo.both": "Both picked", "pickInfo.default": "Community's choice", @@ -111,6 +113,15 @@ "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})", + "match.activeRosterMissing.header": "Active roster needed", + "match.activeRosterMissing.subtitle": "Waiting on {{teams}}", + "match.leagueLocked.header": "Waiting for league round to start", + "match.leagueLocked.subtitle": "Round playable from {{date}} onwards", + "match.locked.header": "Match locked to be casted", + "match.locked.subtitle": "Please wait for staff to unlock", + "match.waitingForTeams.header": "Waiting for teams", + "match.waitingForTeams.subtitle": "Teams will be resolved from earlier matches", + "match.tbd": "TBD", "match.action.undoLastScore": "Undo last score", "match.action.reopenMatch": "Reopen match", "match.action.endSet": "End set", @@ -118,6 +129,18 @@ "match.endSet.selectWinner": "Select Winner", "match.endSet.randomWinner": "Random (50/50)", "match.deadline.explanation": "Please let tournament organizers know about any delays. Matches that go past their deadline may be ended early. Consult tournament rules for details.", + "match.admin.cast": "Cast", + "match.admin.castInfo": "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view.", + "match.admin.castConfigureHint": "Configure streaming channels on the tournament admin page to enable casting.", + "match.admin.notCasted": "Not casted", + "match.admin.unlock": "Unlock", + "match.admin.lockToBeCasted": "Lock to be casted", + "match.admin.lockingInfo": "You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it.", + "match.admin.castChannelUpdated": "Cast channel updated", + "match.admin.editReportedScores": "Edit reported scores", + "match.admin.mapNumber": "Map {{number}}", + "match.admin.winnerWon": "{{teamName}} won", + "match.admin.winnerWonKo": "{{teamName}} won (KO)", "join.error.MISSING_CODE": "Invite code is missing. Was the full URL copied?", "join.error.SHORT_CODE": "Invite code is not the right length. Was the full URL copied?", "join.error.NO_TEAM_MATCHING_CODE": "No team matching the invite code.", @@ -156,6 +179,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B divisions can only be enabled on round robin brackets", "progression.error.AB_DIVISIONS_NOT_STARTING": "A/B divisions can only be enabled on starting brackets", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "A/B divisions requires an even number of teams per group", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "Empty placements are only valid when sourcing from a Swiss bracket with early advance", "lfg.askCaptainToJoinQueue": "Ask your team's captain or a manager to join the queue", "customFlow.beforeSet": "Before set", "customFlow.afterMap": "After map", @@ -187,9 +211,5 @@ "pickBan.banMap": "Ban a map", "pickBan.pickMode": "Pick a mode", "pickBan.banMode": "Ban a mode", - "match.tab.mapInfo": "Map info", - "match.mapInfo.bans": "{{teamName}} bans", - "match.mapInfo.playedStages": "Played stages", - "match.mapInfo.noBans": "No bans", - "match.mapInfo.noPlayedStages": "No stages played yet" + "pickBan.waitingFor": "Waiting for {{teamName}}" } diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json index d009261c2..86c290159 100644 --- a/locales/es-ES/art.json +++ b/locales/es-ES/art.json @@ -10,7 +10,6 @@ "filteringByTag": "Mostrando resultados filtrados por #{{tag}}", "commissionsOpen": "Comisiones abiertas", "commissionsClosed": "Comisiones cerradas", - "openCommissionsOnly": "Mostrar artistas con comisiones abiertas", "gainPerms": "Por favor manda mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir arte. Debes ser el artista que creó el arte que subas, y solo se permite arte relacionado con Splatoon.", "tabs.recentlyUploaded": "Subidas recientemente", "tabs.showcase": "Destacadas", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 7963639bf..8a06e85e3 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -79,11 +79,11 @@ "notifications.title.SCRIM_NEW_REQUEST": "Nueva Solicitud de Scrim", "notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} ha solicitado un scrim", "notifications.title.SCRIM_SCHEDULED": "Scrim Programado", - "notifications.text.SCRIM_SCHEDULED": "Nuevo scrim programado para las {{timeString}}", + "notifications.text.SCRIM_SCHEDULED": "", "notifications.title.SCRIM_CANCELED": "Scrim Cancelado", - "notifications.text.SCRIM_CANCELED": "El scrim de las {{timeString}} fue cancelado", + "notifications.text.SCRIM_CANCELED": "", "notifications.title.SCRIM_STARTING_SOON": "El scrim empieza pronto", - "notifications.text.SCRIM_STARTING_SOON": "Tu scrim de las {{timeString}} empieza pronto", + "notifications.text.SCRIM_STARTING_SOON": "", "notifications.title.COMMISSIONS_CLOSED": "Comisiones Cerradas", "notifications.text.COMMISSIONS_CLOSED": "Si tus comisiones siguen abiertas, por favor vuelve a activarlas", "notifications.title.FRIEND_REQUEST_RECEIVED": "", @@ -143,6 +143,7 @@ "actions.disable": "Desactivar", "actions.accept": "Aceptar", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "Confirmar", "actions.next": "Siguiente", "actions.previous": "Anterior", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Límite de inscripciones", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Clasificatorio", - "tag.name.SZ": "Solo Pintazonas", - "tag.name.TW": "Incluye Territorial", "tag.name.ONES": "1v1", "tag.name.DUOS": "2v2", "tag.name.TRIOS": "3v3", @@ -321,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} ha confirmado el resultado. La partida está cerrada", "chat.systemMsg.cancelReported": "{{name}} pidió cancelar la partida", "chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} abandonó el grupo", "chat.newMessages": "Nuevos mensajes", "chat.sidebar.title": "", @@ -394,6 +394,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 337ed8398..5738fd04e 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -5,9 +5,11 @@ "labels.tag": "Etiqueta", "labels.teamBsky": "Bluesky del equipo", "labels.clockFormat": "Formato de hora", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "Builds: Desactivar orden automático de potenciadores", "labels.disallowScrimPickupsFromUntrusted": "No permitir invitaciones de usuarios no verificados", "labels.noScreen": "[Accesibilidad] Evitar Pantintalla", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome", "bottomTexts.tag": "Normalmente se usa antes del nombre en el juego para indicar pertenencia a un equipo (ej. [TAG] NombreJugador)", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "Automático", "options.clockFormat.24h": "24 horas", "options.clockFormat.12h": "12 horas", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "Este campo es obligatorio", "errors.minLength": "Debe tener al menos {{min}} caracteres", "errors.maxLength": "Debe tener como máximo {{max}} caracteres", @@ -154,8 +160,6 @@ "options.tag.LAN": "LAN", "options.tag.QUALIFIER": "Clasificatorio", "options.tag.COLLEGIATE": "Universitario", - "options.tag.SZ": "Solo Pintazonas", - "options.tag.TW": "Incluye Territorial", "options.tag.ONES": "1v1", "options.tag.DUOS": "2v2", "options.tag.TRIOS": "3v3", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index a74997146..6409331fa 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Dejar grupo", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Dejar fila", "looking.groups.actions.goBack": "Regresar", "looking.groups.adder.quickAdd": "Añadir rapido", @@ -124,6 +125,7 @@ "looking.range.or": "o", "looking.range.or.explanation": "Serás emparejado con un grupo cuyo rango promedio sea uno de estos. El rango exacto no se muestra para evitar que los grupos jueguen únicamente contra niveles inferiores al suyo.", "match.header": "Partido #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado", "match.dispute.button": "¿Disputar?", "match.dispute.p1": "Si hay un error, contacta al otro equipo para que lo corrijan. El resultado puede ser informado libremente hasta que ambos equipos informen el mismo resultado.", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Arma", "match.sides.alpha": "Alfa", "match.sides.bravo": "Bravo", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Helpdesk", "match.pool": "Grupo", "match.password.short": "Codigo", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "Cancelar partido", "match.canceled": "Partido cancelado", + "match.canceled.detail": "", "match.cancelRequested": "Cancelación pedida", "match.reportedBy": "Informado por {{name}} a", "match.cancelPendingConfirmation": "Esperando confirmación del otro equipo", "match.cancelMatch.confirm": "¿Cancelar partido? (requiere confirmación del otro grupo, el abuso de esta función puede darte una prohibición)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "Informar resultado", "match.errors.cantCancel": "No se puede cancelar ya que el oponente ha informado el resultado para este partido. Ver instrucciones para disputar al inicio de la página.", "match.errors.different": "Informaste resultados diferentes a los de tu oponente. Revisa que la información es correcta, o ve las instrucciones para disputar al inicio de la página.", @@ -165,6 +176,56 @@ "match.outcome.loss": "perdida", "match.screen.ban": "Armas con {{special}} son prohibidas para este partido", "match.screen.allowed": "Armas con {{special}} se permiten para este partido", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "Unirte a la fila", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index efd0b87c6..ee145a923 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "Nombre", "forms.managedByAnyone.title": "Cualquiera puede gestionar", "forms.managedByAnyone.explanation": "Si está activado, todos los usuarios en esta publicación pueden aceptar peticiones y eliminar la publicación, no solo el creador.", - "alert.canceled": "Esta scrim fue cancelada por {{user}}. Motivo: {{reason}}", - "maps.header": "Mapas", - "screenBan.header": "Pantalla", - "screenBan.warning": "Pregunta antes de usar la Pantintalla", - "screenBan.allowed": "Nadie en este scrim ha indicado que quiera evitar la Pantintalla" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index b4fbffccf..6a797b611 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Equipo {{number}} elige", "pickInfo.team.specific": "{{team}} eligió", "pickInfo.team.counterpick": "{{team}} hizo contraselección", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Desempate", "pickInfo.both": "Ambos eligieron", "pickInfo.default": "Elección de la comunidad", @@ -113,6 +115,15 @@ "match.pool": "Grupo", "match.score": "{{scoreOne}}-{{scoreTwo}} (Mejor de {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Anular resultado previo", "match.action.reopenMatch": "Reabrir partido", "match.action.endSet": "Finalizar set", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "Seleccionar ganador", "match.endSet.randomWinner": "Aleatorio (50/50)", "match.deadline.explanation": "Informa a los organizadores del torneo sobre cualquier retraso. Los partidos que superen su límite de tiempo pueden terminarse anticipadamente. Consulta las reglas del torneo para más detalles.", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?", "join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?", "join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/es-US/art.json b/locales/es-US/art.json index 0064019cd..4c2ac7c37 100644 --- a/locales/es-US/art.json +++ b/locales/es-US/art.json @@ -10,7 +10,6 @@ "filteringByTag": "Mostrando resultados filtrados por #{{tag}}", "commissionsOpen": "Comisiones abiertas", "commissionsClosed": "Comisiones cerradas", - "openCommissionsOnly": "Mostrar artistas con comisiones abiertas", "gainPerms": "Por favor manda mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir arte. Debes ser el artista que creó el arte que subas, y solo se permite arte relacionada con Splatoon.", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index ae90bbddb..1f8659249 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Limite de entrada", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Clasificatorio", - "tag.name.SZ": "Solo Pintazonas", - "tag.name.TW": "Incluye Territorial", "tag.name.ONES": "1v1", "tag.name.DUOS": "2v2", "tag.name.TRIOS": "3v3", @@ -321,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} confirmó puntuaje. El partido está cerrado", "chat.systemMsg.cancelReported": "{{name}} pidió cancelar el partido", "chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar el partido. El partido está cerrado", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} se salió del grupo", "chat.newMessages": "Nuevo mensajes", "chat.sidebar.title": "", @@ -394,6 +394,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 1869fddc1..457e0aa0c 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index c101ef4c3..9890f0400 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Dejar grupo", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Dejar fila", "looking.groups.actions.goBack": "Regresar", "looking.groups.adder.quickAdd": "Añadir rápido", @@ -124,6 +125,7 @@ "looking.range.or": "o", "looking.range.or.explanation": "", "match.header": "Partido #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado", "match.dispute.button": "¿Disputar?", "match.dispute.p1": "Si hay un error, contacta al otro equipo para que lo corrijan. El resultado puede ser informado libremente hasta que ambos equipos informen el mismo resultado.", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Arma", "match.sides.alpha": "Alfa", "match.sides.bravo": "Bravo", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Helpdesk", "match.pool": "Canal", "match.password.short": "Contraseña", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "Cancelar partido", "match.canceled": "Partido cancelado", + "match.canceled.detail": "", "match.cancelRequested": "Cancelación pedida", "match.reportedBy": "Informado por {{name}} a", "match.cancelPendingConfirmation": "Esperando confirmación del otro equipo", "match.cancelMatch.confirm": "¿Cancelar partido? (requiere confirmación del otro grupo, el abuso de esta función puede darte una prohibición)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "Informar resultado", "match.errors.cantCancel": "No se puede cancelar ya que el oponente ha informado el resultado para este partido. Ver instrucciones para disputar al inicio de la página.", "match.errors.different": "Informaste diferentes resultados que tu oponente. Revisa que la información es correcta, o ve las instrucciones para disputar al inicio de la página.", @@ -165,6 +176,56 @@ "match.outcome.loss": "perdida", "match.screen.ban": "Armas con {{special}} son prohibidas para este partido", "match.screen.allowed": "Armas con {{special}} se permiten para este partido", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "Unirte a la fila", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 7d4156dbf..635d25355 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Equipo {{number}} elige", "pickInfo.team.specific": "{{team}} eligió", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Desempate", "pickInfo.both": "Ambos eligieron", "pickInfo.default": "", @@ -113,6 +115,15 @@ "match.pool": "Canal", "match.score": "{{scoreOne}}-{{scoreTwo}} (Mejor de {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Anular resultado previo", "match.action.reopenMatch": "Reabrir partido", "match.action.endSet": "", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?", "join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?", "join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/fr-CA/art.json b/locales/fr-CA/art.json index 8bff08a1d..438c93ae5 100644 --- a/locales/fr-CA/art.json +++ b/locales/fr-CA/art.json @@ -10,7 +10,6 @@ "filteringByTag": "Affichage des résultats filtrés par #{{tag}}", "commissionsOpen": "Accepte les commissions", "commissionsClosed": "N'accepte pas les commissions", - "openCommissionsOnly": "Ne montrer que les artistes qui acceptent les commissions", "gainPerms": "", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 5f5610359..04a4f39ae 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Limite d'inscriptions", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualifications", - "tag.name.SZ": "DdZ uniquement", - "tag.name.TW": "GdT comprises", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -321,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -394,6 +394,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index bce1360f3..e7cb063b4 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index f3deee494..96ab0754c 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index d7a42f9d0..53116dcb2 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "L'équipe {{number}} fait son choix.", "pickInfo.team.specific": "{{team}} a fait son choix.", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Tiebreaker", "pickInfo.both": "Les deux ont choisi", "pickInfo.default": "", @@ -113,6 +115,15 @@ "match.pool": "Canal}", "match.score": "{{scoreOne}}-{{scoreTwo}} (Meilleur de {{bestOf}})", "match.score.playAll": "", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Annuler le dernier score", "match.action.reopenMatch": "Rouvrir le match", "match.action.endSet": "", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?", "join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?", "join.error.NO_TEAM_MATCHING_CODE": "Aucune équipe ne correspond au code d'invitation.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/fr-EU/art.json b/locales/fr-EU/art.json index f9da97048..1094b9fe0 100644 --- a/locales/fr-EU/art.json +++ b/locales/fr-EU/art.json @@ -10,7 +10,6 @@ "filteringByTag": "Affichage des résultats filtrés par #{{tag}}", "commissionsOpen": "Accepte les commissions", "commissionsClosed": "N'accepte pas les commissions", - "openCommissionsOnly": "Ne montrer que les artistes qui acceptent les commissions", "gainPerms": "Vous pouvez demander dans le salon ''helpdesk'' sur notre discord pour avoir cette permission. Note: vous devez êtres l'artist pour publier votre création, celle-ci doit être seulement en rapport avec Splatoon.", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index dc9fb372c..ab70d87f3 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -79,7 +79,7 @@ "notifications.title.SCRIM_NEW_REQUEST": "Nouvelle Demande De Scrim", "notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} vous demande de scrim", "notifications.title.SCRIM_SCHEDULED": "Scrim Programmé", - "notifications.text.SCRIM_SCHEDULED": "Nouveau scrim programmé à {{timeString}}", + "notifications.text.SCRIM_SCHEDULED": "", "notifications.title.SCRIM_CANCELED": "", "notifications.text.SCRIM_CANCELED": "", "notifications.title.SCRIM_STARTING_SOON": "", @@ -143,6 +143,7 @@ "actions.disable": "Désactiver", "actions.accept": "Accepter", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Limite d'inscriptions", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualifications", - "tag.name.SZ": "DdZ uniquement", - "tag.name.TW": "GdT comprises", "tag.name.ONES": "1v1", "tag.name.DUOS": "2v2", "tag.name.TRIOS": "3v3", @@ -321,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} a confirmé le score. Le match est maintenant vérrouillé", "chat.systemMsg.cancelReported": "{{name}} a demandé d'annuler le match", "chat.systemMsg.cancelConfirmed": "{{name}} a confirmé l'anunulation du match. Le match est maintenant vérrouillé", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} a quitté le groupe", "chat.newMessages": "Nouveau message", "chat.sidebar.title": "", @@ -394,6 +394,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 21f4905ef..6796beb32 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "Team Bluesky", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index ae751f9d9..206727bc9 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Quitter le groupe", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Quitter la queue", "looking.groups.actions.goBack": "Retourner en arrière", "looking.groups.adder.quickAdd": "Ajouter rapidement", @@ -124,6 +125,7 @@ "looking.range.or": "ou", "looking.range.or.explanation": "", "match.header": "Match #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "Les SP après que les deux teams est reportées le même résultat", "match.dispute.button": "Dispute?", "match.dispute.p1": "S'il y a une erreur, contactez l'autre équipe pour la corriger de son côté. Le score peut être librement rapporté jusqu'à ce que les deux équipes rapportent le même résultat.", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Armes", "match.sides.alpha": "Alpha", "match.sides.bravo": "Bravo", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Helpdesk", "match.pool": "Canal", "match.password.short": "Mot de passe", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "Annuler match", "match.canceled": "Match annulé", + "match.canceled.detail": "", "match.cancelRequested": "Annulation demandée", "match.reportedBy": "Reporté par {{name}} à", "match.cancelPendingConfirmation": "En attente de confirmation de l'autre équipe", "match.cancelMatch.confirm": "Annulé le match? (nécessite une confirmation de l'autre groupe, un abus de cette mécanique peut entrainer un ban)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "Score reporté ", "match.errors.cantCancel": "Impossible d'annuler puisque l'adversaire a déjà reporté le score pour ce match. Voir les instructions de contestation en haut de la page.", "match.errors.different": "Vous avez reporté un score different de l'équipe adverse. Vérifiez à nouveau que ce qui précède est correct et sinon consultez les instructions de contestation en haut de la page.", @@ -165,6 +176,56 @@ "match.outcome.loss": "Perdu", "match.screen.ban": "Les armes avec {{special}} ne sont pas autoriser pendant ce match", "match.screen.allowed": "Les armes avec {{special}} sont autoriser pendant ce match", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "Rejoindre la queue", "tiers.currentCriteria": "Critères actuels", "tiers.info.p1": "Par exemple, Les Léviathans font partie des 5 % des meilleurs joueurs. Le diamant est le top 15%, etc.", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index 45a31f30d..92d515ac0 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "Nom", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index acaaa5ef7..ab16b23c4 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "L'équipe {{number}} fait son choix.", "pickInfo.team.specific": "{{team}} a fait son choix.", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Manche décisive", "pickInfo.both": "Les deux ont choisi", "pickInfo.default": "", @@ -113,6 +115,15 @@ "match.pool": "Canal}", "match.score": "{{scoreOne}}-{{scoreTwo}} (Meilleur de {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Tous jouer {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Annuler le dernier score", "match.action.reopenMatch": "Rouvrir le match", "match.action.endSet": "", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?", "join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?", "join.error.NO_TEAM_MATCHING_CODE": "Aucune équipe ne correspond au code d'invitation.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/he/art.json b/locales/he/art.json index 6488f515c..f64df4eb3 100644 --- a/locales/he/art.json +++ b/locales/he/art.json @@ -10,7 +10,6 @@ "filteringByTag": "מראה תוצאות לפי סינון של #{{tag}}", "commissionsOpen": "בקשות פתוחות", "commissionsClosed": "בקשות סגורות", - "openCommissionsOnly": "הראה אומנים עם בקשות פתוחות", "gainPerms": "נא לכתוב בערוץ helpdesk בדיסקורד כדי לקבל הרשאות להעלות ציורים. שימו לב שאתם חייבים להיות יוצר הציור ורק אמנות הקשורה ל-Splatoon מותרת..", "tabs.recentlyUploaded": "הועלה לאחרונה", "tabs.showcase": "תצוגה", diff --git a/locales/he/common.json b/locales/he/common.json index 15a06c15e..b2a529e35 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -203,8 +204,6 @@ "tag.name.COUNT": "מגבלת כניסה", "tag.name.LAN": "רשת מקומית", "tag.name.QUALIFIER": "מוקדמות", - "tag.name.SZ": "רק SZ", - "tag.name.TW": "מכיל TW", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -320,6 +319,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -393,6 +393,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index df8814026..65896bad0 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/he/q.json b/locales/he/q.json index 51b86abed..792e4abdf 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/he/tournament.json b/locales/he/tournament.json index f2c4c9278..75a524a20 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "צוות {{number}} בחרו", "pickInfo.team.specific": "{{team}} נבחר", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "שובר שיוויון", "pickInfo.both": "שניהם בחרו", "pickInfo.default": "", @@ -113,6 +115,15 @@ "match.pool": "פול", "match.score": "{{scoreOne}}-{{scoreTwo}} (הטוב מ-{{bestOf}})", "match.score.playAll": "", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "בטלו את התוצאה האחרונה", "match.action.reopenMatch": "פתיחה מחדש של הקרב", "match.action.endSet": "", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "חסר קוד הזמנה. האם כתובת האתר המלאה הועתקה?", "join.error.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. האם כתובת האתר המלאה הועתקה?", "join.error.NO_TEAM_MATCHING_CODE": "אין צוות שתואם את קוד ההזמנה.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/it/art.json b/locales/it/art.json index a14696355..e09ea1a4d 100644 --- a/locales/it/art.json +++ b/locales/it/art.json @@ -10,7 +10,6 @@ "filteringByTag": "Mostrando risultati filtrati da #{{tag}}", "commissionsOpen": "Commissioni aperte", "commissionsClosed": "Commissioni chiuse", - "openCommissionsOnly": "Mostra artisti con commissioni aperte", "gainPerms": "Si prega di postare sull'helpdesk del nostro Discord per ottenere i permessi per caricare art. Nota che devi essere tu l'artista dell'art che stai caricando, e solo art relative a Splatoon sono ammesse.", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/it/common.json b/locales/it/common.json index b03de3778..5428e9098 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -143,6 +143,7 @@ "actions.disable": "Disattiva", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Limite d'ingresso", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualifica", - "tag.name.SZ": "Solo ZS", - "tag.name.TW": "Include MM", "tag.name.ONES": "1v1", "tag.name.DUOS": "2v2", "tag.name.TRIOS": "3v3", @@ -321,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} ha confermato il punteggio. Il match è ora bloccato", "chat.systemMsg.cancelReported": "{{name}} ha richiesto la cancellazione del match", "chat.systemMsg.cancelConfirmed": "{{name}} ha confermato la cancellazione del match. Il match è ora bloccato", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} ha lasciato il gruppo", "chat.newMessages": "Nuovi messaggi", "chat.sidebar.title": "", @@ -394,6 +394,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 3ebe56146..f77d1f390 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "Bluesky del team", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Nota che se cambi il nome del team, qualcun altro può assumere nome e URL per il proprio team", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/it/q.json b/locales/it/q.json index 885ff5a71..03edcaeb8 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Lascia gruppo", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Lascia coda", "looking.groups.actions.goBack": "Indietro", "looking.groups.adder.quickAdd": "Aggiungi direttamente", @@ -124,6 +125,7 @@ "looking.range.or": "o", "looking.range.or.explanation": "", "match.header": "Match #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "Gli SP verranno sistemati una volta che entrambi i team riporteranno lo stesso punteggio", "match.dispute.button": "Disputa?", "match.dispute.p1": "Se c'è stato un errore contatta l'altro team per farlo correggere dal loro lato. Il punteggio può essere liberamente ririportato finché entrambi i team non lo riportano uguale", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Arma", "match.sides.alpha": "Alpha", "match.sides.bravo": "Bravo", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Helpdesk", "match.pool": "Pool", "match.password.short": "Pass", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "Cancella match", "match.canceled": "Match cancellato", + "match.canceled.detail": "", "match.cancelRequested": "Cancellazione richiesta", "match.reportedBy": "Riportata da {{name}} alle", "match.cancelPendingConfirmation": "Attendendo la conferma dell'altro team", "match.cancelMatch.confirm": "Cancellare match? (Richiede conferma dell'altro gruppo, un abuso di questa funzione comporta un ban)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "Riporta punteggio", "match.errors.cantCancel": "Non puoi cancellare il match, in quanto il nemico ha già riportato un punteggio. Consulta le istruzioni per la disputa di un match sono sopra alla pagina.", "match.errors.different": "Hai riportato risultati diversi da quelli dei tuoi avversari. Controlla due volte che tu abbia inserito il risultato corretto e altrimenti consulta le istruzioni per la disputa sopra alla pagina.", @@ -165,6 +176,56 @@ "match.outcome.loss": "sconfitta", "match.screen.ban": "Armi con {{special}} non sono permesse in questo match", "match.screen.allowed": "Armi con {{special}} non sono permesse in questo match", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "Unisciti alla coda", "tiers.currentCriteria": "Criterio corrente", "tiers.info.p1": "Per esempio Leviathan è la top 5% dei giocatori. Diamante è l' 85esimo percentile etc.", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 41fbead34..d49bc2046 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Scelta del team {{number}}", "pickInfo.team.specific": "{{team}} ha scelto", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Spareggio", "pickInfo.both": "Scelto da entrambi", "pickInfo.default": "", @@ -113,6 +115,15 @@ "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Al meglio di {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Annulla ultimo punteggio", "match.action.reopenMatch": "Riapri match", "match.action.endSet": "", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Il codice invito è mancante. Hai copiato l'intero URL?", "join.error.SHORT_CODE": "Il codice invito non ha la lunghezza giusta. Hai copiato l'intero URL?", "join.error.NO_TEAM_MATCHING_CODE": "Nessun team associato a questo codice invito.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/ja/art.json b/locales/ja/art.json index d36fd4bba..8cf737e9b 100644 --- a/locales/ja/art.json +++ b/locales/ja/art.json @@ -7,7 +7,6 @@ "filteringByTag": "#{{tag}} で絞り込まれた結果を表示中", "commissionsOpen": "依頼を受付中", "commissionsClosed": "依頼の受付なし", - "openCommissionsOnly": "依頼を受付中のアーティストを表示", "gainPerms": "作品をアップロードしたい場合は私たちのディスコードサーバーのヘルプデスクで許可を得てください。アップロードするには作品の作者でないといけません。また、スプラトゥーン関連の作品のみアップロードできます。", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 53eaf2116..ce6b52c44 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -201,8 +202,6 @@ "tag.name.COUNT": "参加数限定", "tag.name.LAN": "オフライン", "tag.name.QUALIFIER": "参加資格", - "tag.name.SZ": "エリアのみ", - "tag.name.TW": "ナワバリあり", "tag.name.ONES": "タイマン (一対一)", "tag.name.DUOS": "二対二", "tag.name.TRIOS": "三対三", @@ -315,6 +314,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}}がスコアを確認しました。試合がロックされました。", "chat.systemMsg.cancelReported": "{{name}} が試合のキャンセルを申請しました", "chat.systemMsg.cancelConfirmed": "{{name}} が試合キャンセルを承認しました。試合がロックされました。", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} がグループから出ました", "chat.newMessages": "新着メッセージ", "chat.sidebar.title": "", @@ -388,6 +388,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 1127db5fd..78e493d6f 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "チームの Bluesky", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index 6cd595f88..53040843e 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "グループを出る", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "列から出る", "looking.groups.actions.goBack": "戻る", "looking.groups.adder.quickAdd": "クイック追加", @@ -124,6 +125,7 @@ "looking.range.or": "か", "looking.range.or.explanation": "", "match.header": "マッチ #{{number}}番", + "match.mapVoters.header": "", "match.spInfo": "SP は両チームが同じ結果を報告してからアップデートされます。", "match.dispute.button": "異議を唱えますか?", "match.dispute.p1": "間違いがあった場合相手側のチームと話し、情報を合わせてください。両チームが同じスコアを報告するまでスコアは再報告できます。", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "武器", "match.sides.alpha": "アルファチーム", "match.sides.bravo": "ブラボーチーム", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "ヘルプデスク", "match.pool": "プール", "match.password.short": "通過", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "マッチをキャンセルする", "match.canceled": "マッチをキャンセルしました", + "match.canceled.detail": "", "match.cancelRequested": "キャンセルを申請しました", "match.reportedBy": "{{name}}によって報告されました: ", "match.cancelPendingConfirmation": "相手チームの了解を待っています", "match.cancelMatch.confirm": "マッチをキャンセルしますか?(相手チームの了解が必要です。使いすぎるとバンされます。)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "スコアを報告", "match.errors.cantCancel": "相手がスコアを報告したためキャンセルできません。ページの上の異議申立ての仕方を見てください。", "match.errors.different": "報告したスコアが相手と違います。今一度ご確認し、それでも違う場合は、ページの上の異議申立ての仕方を見てください。", @@ -165,6 +176,56 @@ "match.outcome.loss": "負け", "match.screen.ban": "{{special}}が付いてる武器はこのマッチでは禁止です", "match.screen.allowed": "{{special}}が付いてる武器は使用可能です", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "列に入る", "tiers.currentCriteria": "現在の基準", "tiers.info.p1": "例として、Leviathanはプレイヤーの上位5%、Diamondは上位15%", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 49fbfd7f9..af05ab87b 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "チーム {{number}} の選択", "pickInfo.team.specific": "{{team}} picked", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "タイブレイカー", "pickInfo.both": "両者選択", "pickInfo.default": "", @@ -107,6 +109,15 @@ "match.pool": "プール", "match.score": "{{scoreOne}}-{{scoreTwo}} (Best of {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全てプレイする {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "最後のスコアをやりなおす", "match.action.reopenMatch": "対戦を再度開く", "match.action.endSet": "", @@ -114,6 +125,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "招待コードがみつかりません。すべての URL をコピーしましたか?", "join.error.SHORT_CODE": "招待コードの長さが正しくありません。すべての URL をコピーしましたか?", "join.error.NO_TEAM_MATCHING_CODE": "この招待コードにあうチームがみつかりません。", @@ -152,6 +175,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -183,9 +207,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/ko/art.json b/locales/ko/art.json index 8e8b0deda..6b365fbd1 100644 --- a/locales/ko/art.json +++ b/locales/ko/art.json @@ -7,7 +7,6 @@ "filteringByTag": "", "commissionsOpen": "", "commissionsClosed": "", - "openCommissionsOnly": "", "gainPerms": "", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6deb1b74d..5a1cb1723 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -201,8 +202,6 @@ "tag.name.COUNT": "참가 제한", "tag.name.LAN": "현장 이벤트", "tag.name.QUALIFIER": "예선전", - "tag.name.SZ": "에어리어 한정", - "tag.name.TW": "영역 배틀 포함", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -315,6 +314,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -388,6 +388,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index fe64b48dc..3b3419e77 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 3355040aa..1728323c7 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index a0d03e2e6..4c3b40ed3 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "", "pickInfo.team.specific": "", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "", "pickInfo.both": "", "pickInfo.default": "", @@ -107,6 +109,15 @@ "match.pool": "", "match.score": "", "match.score.playAll": "", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "", "match.action.reopenMatch": "", "match.action.endSet": "", @@ -114,6 +125,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "", "join.error.SHORT_CODE": "", "join.error.NO_TEAM_MATCHING_CODE": "", @@ -152,6 +175,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -183,9 +207,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/nl/art.json b/locales/nl/art.json index d75450428..26d9f7108 100644 --- a/locales/nl/art.json +++ b/locales/nl/art.json @@ -9,7 +9,6 @@ "filteringByTag": "", "commissionsOpen": "", "commissionsClosed": "", - "openCommissionsOnly": "", "gainPerms": "", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 17367f1db..97546220e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -203,8 +204,6 @@ "tag.name.COUNT": "Beperkte deelname", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualificatie", - "tag.name.SZ": "", - "tag.name.TW": "", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -319,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -392,6 +392,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index a3d5f9b6c..095174289 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 3355040aa..1728323c7 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 9147a4b26..b5439ca29 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "", "pickInfo.team.specific": "", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "", "pickInfo.both": "", "pickInfo.default": "", @@ -111,6 +113,15 @@ "match.pool": "", "match.score": "", "match.score.playAll": "", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "", "match.action.reopenMatch": "", "match.action.endSet": "", @@ -118,6 +129,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "", "join.error.SHORT_CODE": "", "join.error.NO_TEAM_MATCHING_CODE": "", @@ -156,6 +179,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -187,9 +211,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/pl/art.json b/locales/pl/art.json index 8c1aece3c..4d96340cd 100644 --- a/locales/pl/art.json +++ b/locales/pl/art.json @@ -11,7 +11,6 @@ "filteringByTag": "", "commissionsOpen": "", "commissionsClosed": "", - "openCommissionsOnly": "", "gainPerms": "", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index e4252c03c..eafdef11b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Limit zapisów", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Qualifier", - "tag.name.SZ": "", - "tag.name.TW": "", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -322,6 +321,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", @@ -395,6 +395,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 9149f2586..d6d7c5ce5 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Uwaga: Jeśli zmienisz imię drużyny, ktoś inny może użyć twoje stare imię i URL", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 3355040aa..1728323c7 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", "looking.groups.adder.quickAdd": "", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "", "match.sides.alpha": "", "match.sides.bravo": "", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "", "match.pool": "", "match.password.short": "", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "", "match.canceled": "", + "match.canceled.detail": "", "match.cancelRequested": "", "match.reportedBy": "", "match.cancelPendingConfirmation": "", "match.cancelMatch.confirm": "", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "", "match.errors.cantCancel": "", "match.errors.different": "", @@ -165,6 +176,56 @@ "match.outcome.loss": "", "match.screen.ban": "", "match.screen.allowed": "", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 62af58676..d733a303f 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Drużyna {{number}} wybiera", "pickInfo.team.specific": "", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Tiebreaker", "pickInfo.both": "Oba wybrane", "pickInfo.default": "", @@ -115,6 +117,15 @@ "match.pool": "", "match.score": "", "match.score.playAll": "", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "", "match.action.reopenMatch": "", "match.action.endSet": "", @@ -122,6 +133,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "", "join.error.SHORT_CODE": "", "join.error.NO_TEAM_MATCHING_CODE": "", @@ -160,6 +183,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -191,9 +215,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/pt-BR/art.json b/locales/pt-BR/art.json index 60d87e22e..9fd086fdc 100644 --- a/locales/pt-BR/art.json +++ b/locales/pt-BR/art.json @@ -10,7 +10,6 @@ "filteringByTag": "Mostrando resultados filtrados por/pela #{{tag}}", "commissionsOpen": "Comissões estão abertas", "commissionsClosed": "Comissões estão fechadas", - "openCommissionsOnly": "Mostrar somente artistas com comissões abertas", "gainPerms": "Por favor, poste na central de ajuda (helpdesk em Inglês) do nosso Discord para ganhar permissões para fazer o upload de arte. Lembre-se que você precisa ser o artista da arte da qual você está fazendo o upload e que apenas arte relacionada com Splatoon é permitida.", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 47549b85b..e17797ddc 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Vagas limitadas", "tag.name.LAN": "LAN (Local)", "tag.name.QUALIFIER": "Qualificatória", - "tag.name.SZ": "Apenas Zones", - "tag.name.TW": "Inclui Turf", "tag.name.ONES": "", "tag.name.DUOS": "", "tag.name.TRIOS": "", @@ -321,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} confirmou a pontuação. A partida foi trancada", "chat.systemMsg.cancelReported": "{{name}} solicitou o cancelamento da partida", "chat.systemMsg.cancelConfirmed": "{{name}} confirmou o cancelamento da partida. A partida foi trancada", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} deixou o grupo", "chat.newMessages": "", "chat.sidebar.title": "", @@ -394,6 +394,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index ad2f85eb5..8d3957031 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Lembre-se que se você mudar o nome do seu time, alguém pode resgatar o nome e o URL para o time dele(a)", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index dc50c38ab..3df8792a1 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Sair do grupo", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Sair da fila", "looking.groups.actions.goBack": "Voltar", "looking.groups.adder.quickAdd": "Adicionar rapidamente (quick add)", @@ -124,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "Partida #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "SP será ajustado depois que ambos os times declarem o mesmo resultado", "match.dispute.button": "Disputar?", "match.dispute.p1": "Se há algum erro entre em contato com o outro time para que eles corrijam do lado deles. As pontuações podem ser redeclaradas livremente até que ambos os times declarem a mesma pontuação.", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Arma", "match.sides.alpha": "Alpha", "match.sides.bravo": "Bravo", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Helpdesk", "match.pool": "Pool", "match.password.short": "Senha", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "Cancelar partida", "match.canceled": "Partida cancelada", + "match.canceled.detail": "", "match.cancelRequested": "Cancelamento solicitado", "match.reportedBy": "Declarado por {{name}} às", "match.cancelPendingConfirmation": "Confirmação do outro time pendente", "match.cancelMatch.confirm": "Cancelar a partida? (isso requer a confirmação do outro grupo, abuso dessa ferramenta pode levar a um banimento)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "Declarar pontuação", "match.errors.cantCancel": "Não é possível cancelar pois o oponente já declarou uma pontuação para essa partida. Veja as instruções de disputa no topo da página.", "match.errors.different": "Você declarou um resultado diferente do seu oponente. Veja novamente se o que está acima está correto e, caso contrário, veja as instruções de disputa no topo da página.", @@ -165,6 +176,56 @@ "match.outcome.loss": "derrota", "match.screen.ban": "Armas com o/a {{special}} não são permitidas nessa partida", "match.screen.allowed": "Armas com o/a {{special}} são permitidas nessa partida", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "Entrar na fila", "tiers.currentCriteria": "Critérios atuais", "tiers.info.p1": "Por exemplo, Leviathan é o top 5% dos jogadores. Diamond é top 15% e etc.", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index cf5869250..0e977c76b 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Escolha do time {{number}}", "pickInfo.team.specific": "{{team}} escolheu", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Desempate", "pickInfo.both": "Ambos escolheram", "pickInfo.default": "", @@ -113,6 +115,15 @@ "match.pool": "Pool", "match.score": "{{scoreOne}}-{{scoreTwo}} (Melhor de {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jogar todas {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Desfazer última pontuação", "match.action.reopenMatch": "Reabrir partida", "match.action.endSet": "", @@ -120,6 +131,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "O código de convite está faltando. O URL foi copiado completamente?", "join.error.SHORT_CODE": "O código de convite está com o comprimento incorreto. O URL foi copiado completamente?", "join.error.NO_TEAM_MATCHING_CODE": "Nenhum time corresponde ao código de convite.", @@ -158,6 +181,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -189,9 +213,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/ru/art.json b/locales/ru/art.json index 398ceb4ba..14dd7f67f 100644 --- a/locales/ru/art.json +++ b/locales/ru/art.json @@ -11,7 +11,6 @@ "filteringByTag": "Результаты по фильтру #{{tag}}", "commissionsOpen": "Заказы открыты", "commissionsClosed": "Заказы закрыты", - "openCommissionsOnly": "Показать художников с открытыми заказами", "gainPerms": "Пожалуйста, напишите в helpdesk на нашем Discord сервере, чтобы получить доступ к загрузке артов. Учтите, что вы должны быть автором артов, которые вы загружаете. Арты должны быть строго по тематике Splatoon.", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2f2d22a5a..8ae3ea72d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -79,7 +79,7 @@ "notifications.title.SCRIM_NEW_REQUEST": "Новый Скрим Запрос", "notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} запросил скрим", "notifications.title.SCRIM_SCHEDULED": "Скрим Запланирован", - "notifications.text.SCRIM_SCHEDULED": "Новый скрим запланирован на {{timeString}}", + "notifications.text.SCRIM_SCHEDULED": "", "notifications.title.SCRIM_CANCELED": "", "notifications.text.SCRIM_CANCELED": "", "notifications.title.SCRIM_STARTING_SOON": "", @@ -143,6 +143,7 @@ "actions.disable": "Выключить", "actions.accept": "Подтвердить", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -204,8 +205,6 @@ "tag.name.COUNT": "Лимит участников", "tag.name.LAN": "LAN", "tag.name.QUALIFIER": "Квалификационный", - "tag.name.SZ": "Только Бой за зоны", - "tag.name.TW": "Включает Бой за район", "tag.name.ONES": "1 на 1", "tag.name.DUOS": "2 на 2", "tag.name.TRIOS": "3 на 3", @@ -322,6 +321,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} подтвердил счёт. Матч теперь закрыт", "chat.systemMsg.cancelReported": "{{name}} запросил отмену матча", "chat.systemMsg.cancelConfirmed": "{{name}} подтвердил отмену матча. Матч теперь закрыт", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} покинул группу", "chat.newMessages": "Новые сообщения", "chat.sidebar.title": "", @@ -395,6 +395,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 952426f0f..98c0040b3 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "Bluesky команды", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index 65fbf5144..41a4abf8e 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "Покинуть группу", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Покинуть очередь", "looking.groups.actions.goBack": "Вернуться назад", "looking.groups.adder.quickAdd": "Быстро добавить", @@ -124,6 +125,7 @@ "looking.range.or": "или", "looking.range.or.explanation": "", "match.header": "Матч #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "SP будет изменено после того, как обе команды сообщат одинаковый счёт.", "match.dispute.button": "Оспорить?", "match.dispute.p1": "Если участниками другой команды была допущена ошибка, то попросите их исправить её. Пока обе команды не сообщат одинаковый счёт, то его можно свободно изменить.", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "Оружие", "match.sides.alpha": "Альфа", "match.sides.bravo": "Браво", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Служба поддержки", "match.pool": "Канал", "match.password.short": "Пароль", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "Отменить матч", "match.canceled": "Матч был отменён", + "match.canceled.detail": "", "match.cancelRequested": "Запрос об отмене матча", "match.reportedBy": "Сообщено {{name}}, время -", "match.cancelPendingConfirmation": "Ждёт подтверждения другой команды", "match.cancelMatch.confirm": "Отменить матч? (требует подтверждения другой команды, злоупотребление может привести к бану)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "Сообщить счёт", "match.errors.cantCancel": "Невозможно отменить, так как другая команда уже сообщила счёт за этот матч. Ознакомьтесь с инструкцией по оспорению на верху страницы.", "match.errors.different": "Вы сообщили счёт отличный от того, который сообщила другая команда. Проверьте достоверность счёта, иначе ознакомьтесь с инструкцией по оспорению на верху страницы.", @@ -165,6 +176,56 @@ "match.outcome.loss": "проигрыш", "match.screen.ban": "Оружия с {{special}} запрещены в этом матче", "match.screen.allowed": "Оружия с {{special}} разрешены в этом матче", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "Присоединиться к очереди", "tiers.currentCriteria": "Текущие критерии", "tiers.info.p1": "Например, Leviathan - топ 5% игроков, Diamond - 85 процентиль и т.д.", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index b4bad5b52..bfabb645a 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "Выбор команды {{number}}", "pickInfo.team.specific": "", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "Тай-брейк", "pickInfo.both": "Выбрано обеими", "pickInfo.default": "", @@ -115,6 +117,15 @@ "match.pool": "Канал", "match.score": "{{scoreOne}}-{{scoreTwo}} (Лучшее из {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Играть все {{bestOf}})", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "Отменить последний результат", "match.action.reopenMatch": "Открыть матч заново", "match.action.endSet": "", @@ -122,6 +133,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "Код приглашения отсутствует. Был ли URL скопирован полностью?", "join.error.SHORT_CODE": "Длина кода приглашения неверна. Был ли URL скопирован полностью?", "join.error.NO_TEAM_MATCHING_CODE": "Нет команды, соответствующей коду приглашения.", @@ -160,6 +183,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -191,9 +215,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/locales/zh/art.json b/locales/zh/art.json index fa63e8ead..843f81861 100644 --- a/locales/zh/art.json +++ b/locales/zh/art.json @@ -7,7 +7,6 @@ "filteringByTag": "正在显示包含 #{{tag}} 的结果", "commissionsOpen": "委托开放中", "commissionsClosed": "委托未开放", - "openCommissionsOnly": "显示开放委托的创作者", "gainPerms": "请在我们Discord中的helpdesk申请上传作品的权限。请注意您必须是作品的原作者并且只能上传斯普拉遁相关作品。", "tabs.recentlyUploaded": "", "tabs.showcase": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 8af1e24ee..884034e3a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -143,6 +143,7 @@ "actions.disable": "", "actions.accept": "", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -201,8 +202,6 @@ "tag.name.COUNT": "队伍数量限制", "tag.name.LAN": "线下", "tag.name.QUALIFIER": "资格赛", - "tag.name.SZ": "仅限区域模式", - "tag.name.TW": "包含占地对战", "tag.name.ONES": "1v1", "tag.name.DUOS": "2v2", "tag.name.TRIOS": "3v3", @@ -315,6 +314,7 @@ "chat.systemMsg.scoreConfirmed": "{{name}} 确认比分,本次对战已锁定", "chat.systemMsg.cancelReported": "{{name}} 申请取消对战", "chat.systemMsg.cancelConfirmed": "{{name}} 确认取消对战,本次对战已锁定", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} 离开了小队", "chat.newMessages": "新消息", "chat.sidebar.title": "", @@ -388,6 +388,7 @@ "search.type.weapons": "", "search.noResults": "", "search.hint": "", + "search.searching": "", "dataCredit.lean": "", "header.parameter": "", "weaponArt.title": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 02619d2ea..1705f1dd7 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -5,9 +5,11 @@ "labels.tag": "", "labels.teamBsky": "", "labels.clockFormat": "", + "labels.dateFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "请注意,如果您更改了队名,那么其他人便可以使用之前的队名和URL了。", "bottomTexts.tag": "", @@ -18,6 +20,10 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.dateFormat.auto": "", + "options.dateFormat.MDY": "", + "options.dateFormat.DMY": "", + "options.dateFormat.YMD": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -154,8 +160,6 @@ "options.tag.LAN": "", "options.tag.QUALIFIER": "", "options.tag.COLLEGIATE": "", - "options.tag.SZ": "", - "options.tag.TW": "", "options.tag.ONES": "", "options.tag.DUOS": "", "options.tag.TRIOS": "", diff --git a/locales/zh/q.json b/locales/zh/q.json index 0b694df57..69b5e9355 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -100,6 +100,7 @@ "looking.groups.actions.leaveGroup": "离开小队", "looking.groups.actions.stopLooking": "", "looking.groups.actions.stopLooking.confirm": "", + "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "停止匹配", "looking.groups.actions.goBack": "返回", "looking.groups.adder.quickAdd": "快速添加", @@ -124,6 +125,7 @@ "looking.range.or": "或", "looking.range.or.explanation": "", "match.header": "对战 #{{number}}", + "match.mapVoters.header": "", "match.spInfo": "SP会在双方提交相同的比分后改变", "match.dispute.button": "分歧?", "match.dispute.p1": "如果比分有误,请联系对手修改。比分可以重新提交,知道双方提交的比分相同为止。", @@ -142,15 +144,24 @@ "match.report.weaponLabel": "武器", "match.sides.alpha": "Alpha", "match.sides.bravo": "Bravo", + "match.groupAlpha": "", + "match.groupBravo": "", "match.helpdesk": "Helpdesk", "match.pool": "频道", "match.password.short": "密码", + "match.hostedBy": "", + "match.noSplatnetWarning": "", + "match.room.noRoomHint": "", + "match.room.stalePrompt": "", + "match.room.confirm": "", "match.cancelMatch": "取消对战", "match.canceled": "对战已取消", + "match.canceled.detail": "", "match.cancelRequested": "已申请取消", "match.reportedBy": "由 {{name}} 提交于", "match.cancelPendingConfirmation": "等待对手确认", "match.cancelMatch.confirm": "要取消对战吗?(需要对手确认,滥用此功能会被封禁)", + "match.adminCancel.confirm": "", "match.tabs.reportScore": "提交比分", "match.errors.cantCancel": "对手已经提交对战比分,所以无法取消。请看页面顶部的分歧规则。", "match.errors.different": "您提交的比分和对手不同。请再次确认比分是否正确,或查看页面顶部的分歧规则。", @@ -165,6 +176,56 @@ "match.outcome.loss": "负", "match.screen.ban": "本次对战禁止使用特殊武器为 {{special}} 的武器", "match.screen.allowed": "本次对战允许使用特殊武器为 {{special}} 的武器", + "match.subbedOut": "", + "match.action.selectWinner": "", + "match.action.myTeam": "", + "match.action.opponent": "", + "match.action.ko": "", + "match.action.confirmSetEnding": "", + "match.action.requestCancel": "", + "match.action.adminCancel": "", + "match.action.acceptCancelingSet": "", + "match.action.pickStage": "", + "match.action.banStage": "", + "match.action.pickMode": "", + "match.action.banMode": "", + "match.action.pickerUs": "", + "match.action.pickerThem": "", + "match.action.pickerBoth": "", + "match.action.pickBanPrompt": "", + "match.action.pickBanWaiting": "", + "match.action.picking": "", + "match.action.banning": "", + "match.cancelRequested.subtitle": "", + "match.timeline.win": "", + "match.timeline.loss": "", + "match.timeline.out": "", + "match.timeline.in": "", + "match.timeline.live": "", + "match.timeline.picked": "", + "match.waitingForConfirmation": "", + "match.undoReport": "", + "match.weapon.yourWeapon": "", + "match.weapon.undoWeapon": "", + "match.confirmScore": "", + "match.confirmScore.wrongHint": "", + "match.rematch.prompt": "", + "match.rematch.resolved": "", + "match.rematch.vote.yes": "", + "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", + "match.rematch.declined": "", + "match.rematch.fizzled": "", + "match.rematch.waitingCaptain": "", + "match.rematch.rejoinQueue": "", + "match.rematch.backToQueue": "", + "match.banner.final": "", + "match.banner.bestOf": "", + "match.banner.playAll": "", + "match.banner.vs": "", + "match.tabs.rosters": "", + "match.tabs.action": "", + "match.tabs.result": "", "preparing.joinQ": "开始匹配", "tiers.currentCriteria": "当前规则", "tiers.info.p1": "比如说,Leviathan是前5%的玩家,Diamond是前15%的玩家。", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 5390e098b..76c07c585 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -86,9 +86,8 @@ "associations.forms.name.title": "", "forms.managedByAnyone.title": "", "forms.managedByAnyone.explanation": "", - "alert.canceled": "", - "maps.header": "", - "screenBan.header": "", - "screenBan.warning": "", - "screenBan.allowed": "" + "banner.canceled.header": "", + "banner.canceled.subtitle": "", + "banner.freeForm.header": "", + "banner.freeForm.subtitle": "" } diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 71f639be3..7296ef9f4 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -48,6 +48,8 @@ "pickInfo.team": "队伍 {{number}} 选择", "pickInfo.team.specific": "{{team}} 选择", "pickInfo.team.counterpick": "", + "pickInfo.pickedBy": "", + "pickInfo.counterpickedBy": "", "pickInfo.tiebreaker": "决胜局", "pickInfo.both": "双方都选择", "pickInfo.default": "", @@ -107,6 +109,15 @@ "match.pool": "频道", "match.score": "{{scoreOne}}-{{scoreTwo}} (Bo {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全部 {{bestOf}} 场)", + "match.activeRosterMissing.header": "", + "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", + "match.locked.header": "", + "match.locked.subtitle": "", + "match.waitingForTeams.header": "", + "match.waitingForTeams.subtitle": "", + "match.tbd": "", "match.action.undoLastScore": "撤销上次比分", "match.action.reopenMatch": "重新开始对战", "match.action.endSet": "", @@ -114,6 +125,18 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.admin.cast": "", + "match.admin.castInfo": "", + "match.admin.castConfigureHint": "", + "match.admin.notCasted": "", + "match.admin.unlock": "", + "match.admin.lockToBeCasted": "", + "match.admin.lockingInfo": "", + "match.admin.castChannelUpdated": "", + "match.admin.editReportedScores": "", + "match.admin.mapNumber": "", + "match.admin.winnerWon": "", + "match.admin.winnerWonKo": "", "join.error.MISSING_CODE": "缺少邀请码。您是否复制了完整URL?", "join.error.SHORT_CODE": "邀请码长度不符。您是否复制了完整URL?", "join.error.NO_TEAM_MATCHING_CODE": "没有队伍与邀请码相匹配。", @@ -152,6 +175,7 @@ "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", "progression.error.AB_DIVISIONS_NOT_STARTING": "", "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", @@ -183,9 +207,5 @@ "pickBan.banMap": "", "pickBan.pickMode": "", "pickBan.banMode": "", - "match.tab.mapInfo": "", - "match.mapInfo.bans": "", - "match.mapInfo.playedStages": "", - "match.mapInfo.noBans": "", - "match.mapInfo.noPlayedStages": "" + "pickBan.waitingFor": "" } diff --git a/migrations/134-calendar-event-strip-mode-tags.js b/migrations/134-calendar-event-strip-mode-tags.js new file mode 100644 index 000000000..3caa20517 --- /dev/null +++ b/migrations/134-calendar-event-strip-mode-tags.js @@ -0,0 +1,25 @@ +export function up(db) { + db.transaction(() => { + const rows = db + .prepare( + /* sql */ `select "id", "tags" from "CalendarEvent" where "tags" is not null`, + ) + .all(); + + const stmt = db.prepare( + /* sql */ `update "CalendarEvent" set "tags" = ? where "id" = ?`, + ); + + for (const row of rows) { + const cleaned = row.tags + .split(",") + .filter((tag) => tag !== "SZ" && tag !== "TW") + .join(","); + const newTags = cleaned === "" ? null : cleaned; + + if (newTags !== row.tags) { + stmt.run(newTags, row.id); + } + } + })(); +} diff --git a/migrations/135-tournament-team-check-in-unique.js b/migrations/135-tournament-team-check-in-unique.js new file mode 100644 index 000000000..325e79ee8 --- /dev/null +++ b/migrations/135-tournament-team-check-in-unique.js @@ -0,0 +1,19 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + delete from "TournamentTeamCheckIn" + where "rowid" not in ( + select max("rowid") + from "TournamentTeamCheckIn" + group by "tournamentTeamId", coalesce("bracketIdx", -1) + )`, + ).run(); + + db.prepare( + /* sql */ ` + create unique index "tournament_team_check_in_team_bracket_unique" + on "TournamentTeamCheckIn"("tournamentTeamId", coalesce("bracketIdx", -1))`, + ).run(); + })(); +} diff --git a/migrations/136-fix-empty-placements-progression.js b/migrations/136-fix-empty-placements-progression.js new file mode 100644 index 000000000..597477417 --- /dev/null +++ b/migrations/136-fix-empty-placements-progression.js @@ -0,0 +1,19 @@ +const TOURNAMENT_ID = 1078; + +export function up(db) { + const row = db + .prepare(/* sql */ `select "settings" from "Tournament" where "id" = ?`) + .get(TOURNAMENT_ID); + + if (!row) return; + + const settings = JSON.parse(row.settings); + const source = settings.bracketProgression?.[1]?.sources?.[0]; + if (!source || source.placements?.length !== 0) return; + + source.placements = [-1]; + + db.prepare( + /* sql */ `update "Tournament" set "settings" = ? where "id" = ?`, + ).run(JSON.stringify(settings), TOURNAMENT_ID); +} diff --git a/migrations/137-match-page.js b/migrations/137-match-page.js new file mode 100644 index 000000000..c386968a8 --- /dev/null +++ b/migrations/137-match-page.js @@ -0,0 +1,172 @@ +export function up(db) { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "RoomLink" ( + "userId" integer not null unique, + "url" text not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + "refreshedAt" integer default (strftime('%s', 'now')) not null, + foreign key ("userId") references "User"("id") on delete cascade + ) strict + `, + ).run(); + + db.prepare( + /* sql */ `alter table "User" add "noSplatnet" integer default 0 not null`, + ).run(); + + db.prepare( + /* sql */ `alter table "Group" add "matchmade" integer default 0 not null`, + ).run(); + + db.prepare( + /* sql */ `alter table "GroupMatchMap" add "reportedAt" integer`, + ).run(); + + db.prepare( + /* sql */ `alter table "GroupMatchMap" add "reportedByUserId" integer references "User"("id")`, + ).run(); + + db.prepare( + /* sql */ ` + update "GroupMatchMap" + set + "reportedAt" = ( + select "reportedAt" from "GroupMatch" + where "GroupMatch"."id" = "GroupMatchMap"."matchId" + ), + "reportedByUserId" = ( + select "reportedByUserId" from "GroupMatch" + where "GroupMatch"."id" = "GroupMatchMap"."matchId" + ) + where "winnerGroupId" is not null + `, + ).run(); + + db.prepare( + /* sql */ ` + create table "GroupMatch_new" ( + "id" integer primary key, + "alphaGroupId" integer not null, + "bravoGroupId" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + "chatCode" text, + "memento" text, + "confirmedAt" integer, + "confirmedByUserId" integer references "User"("id"), + "cancelRequestedByUserId" integer references "User"("id"), + "cancelAcceptedByUserId" integer references "User"("id"), + foreign key ("alphaGroupId") references "Group"("id") on delete restrict, + foreign key ("bravoGroupId") references "Group"("id") on delete restrict, + unique("alphaGroupId") on conflict rollback, + unique("bravoGroupId") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + insert into "GroupMatch_new" ( + "id", "alphaGroupId", "bravoGroupId", "createdAt", "chatCode", "memento" + ) + select + "id", "alphaGroupId", "bravoGroupId", "createdAt", "chatCode", "memento" + from "GroupMatch" + `, + ).run(); + + db.prepare(/* sql */ `drop table "GroupMatch"`).run(); + db.prepare( + /* sql */ `alter table "GroupMatch_new" rename to "GroupMatch"`, + ).run(); + + db.prepare( + /* sql */ `create index group_match_alpha_group_id on "GroupMatch"("alphaGroupId")`, + ).run(); + db.prepare( + /* sql */ `create index group_match_bravo_group_id on "GroupMatch"("bravoGroupId")`, + ).run(); + db.prepare( + /* sql */ `create index group_match_created_at on "GroupMatch"("createdAt")`, + ).run(); + db.prepare( + /* sql */ `create index group_match_confirmed_at on "GroupMatch"("confirmedAt")`, + ).run(); + + db.prepare( + /* sql */ `create index group_match_map_reported_at on "GroupMatchMap"("reportedAt")`, + ).run(); + + db.prepare( + /* sql */ ` + create table "GroupMatchContinueVote" ( + "id" integer primary key, + "groupId" integer not null, + "userId" integer not null, + "isContinuing" integer not null check ("isContinuing" in (0, 1)), + "votedAt" integer default (strftime('%s', 'now')) not null, + foreign key ("groupId") references "Group"("id") on delete cascade, + foreign key ("userId") references "User"("id") on delete cascade, + unique("groupId", "userId") + ) strict + `, + ).run(); + + db.prepare( + /* sql */ `create index group_match_continue_vote_group_id on "GroupMatchContinueVote"("groupId")`, + ).run(); + + db.prepare( + /* sql */ ` + create table "ReportedWeapon_new" ( + "groupMatchId" integer, + "tournamentMatchId" integer, + "mapIndex" integer not null, + "weaponSplId" integer not null, + "userId" integer not null, + foreign key ("groupMatchId") references "GroupMatch"("id") on delete cascade, + foreign key ("tournamentMatchId") references "TournamentMatch"("id") on delete cascade, + foreign key ("userId") references "User"("id") on delete restrict, + unique("groupMatchId", "mapIndex", "userId") on conflict rollback, + unique("tournamentMatchId", "mapIndex", "userId") on conflict rollback, + check (("groupMatchId" is not null) <> ("tournamentMatchId" is not null)) + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + insert into "ReportedWeapon_new" ( + "groupMatchId", "tournamentMatchId", "mapIndex", "weaponSplId", "userId" + ) + select + "GroupMatchMap"."matchId", null, "GroupMatchMap"."index", + "ReportedWeapon"."weaponSplId", "ReportedWeapon"."userId" + from "ReportedWeapon" + inner join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId" + `, + ).run(); + + db.prepare(/* sql */ `drop table "ReportedWeapon"`).run(); + db.prepare( + /* sql */ `alter table "ReportedWeapon_new" rename to "ReportedWeapon"`, + ).run(); + + db.prepare( + /* sql */ `create index reported_weapon_group_match_id on "ReportedWeapon"("groupMatchId")`, + ).run(); + db.prepare( + /* sql */ `create index reported_weapon_tournament_match_id on "ReportedWeapon"("tournamentMatchId")`, + ).run(); + db.prepare( + /* sql */ `create index reported_weapon_user_id on "ReportedWeapon"("userId")`, + ).run(); + + db.pragma("foreign_key_check"); + })(); + + db.pragma("foreign_keys = ON"); +} diff --git a/package.json b/package.json index 1822f1c1b..90a5b55e7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "biome:fix": "biome check --error-on-warnings --write .", "biome:fix:unsafe": "biome check --error-on-warnings --write --unsafe .", "typecheck": "react-router typegen && tsc --noEmit", + "typecheck:scripts": "tsc --noEmit -p scripts", "test:unit:browser": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 BROWSER_HEADLESS=true vitest --silent=passed-only run", "test:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only --project browser", "test:unit:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only", @@ -36,8 +37,8 @@ "sync-weapon-params": "tsx scripts/sync-weapon-params.ts" }, "dependencies": { - "@aws-sdk/client-s3": "3.1030.0", - "@aws-sdk/lib-storage": "3.1030.0", + "@aws-sdk/client-s3": "3.1037.0", + "@aws-sdk/lib-storage": "3.1037.0", "@date-fns/tz": "1.4.1", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -45,10 +46,10 @@ "@dnd-kit/utilities": "3.2.2", "@epic-web/cachified": "5.6.2", "@faker-js/faker": "10.4.0", - "@formatjs/intl-durationformat": "0.10.4", + "@formatjs/intl-durationformat": "0.10.5", "@internationalized/date": "3.12.1", - "@react-router/node": "7.14.1", - "@react-router/serve": "7.14.1", + "@react-router/node": "7.14.2", + "@react-router/serve": "7.14.2", "@remix-run/form-data-parser": "0.16.0", "@tldraw/tldraw": "3.12.1", "@zumer/snapdom": "2.9.0", @@ -59,46 +60,48 @@ "date-fns": "4.1.0", "edmonds-blossom-fixed": "1.0.1", "gray-matter": "4.0.3", - "i18next": "25.10.10", + "i18next": "26.0.8", "i18next-browser-languagedetector": "8.2.1", - "i18next-http-backend": "3.0.5", - "ics": "3.11.0", - "isbot": "5.1.38", + "i18next-http-backend": "3.0.6", + "ics": "3.12.0", + "isbot": "5.1.39", "jsoncrush": "1.1.8", "kysely": "0.28.16", "lru-cache": "11.3.5", - "lucide-react": "1.8.0", - "markdown-to-jsx": "9.7.15", + "lucide-react": "1.11.0", + "markdown-to-jsx": "9.7.16", "nanoid": "5.1.9", "neverthrow": "8.2.0", "node-cron": "4.2.1", "nprogress": "0.2.0", "openskill": "4.1.1", "p-limit": "7.3.0", - "partysocket": "1.1.16", + "partysocket": "1.1.18", + "qrcode.react": "4.2.0", "react": "19.2.5", "react-aria-components": "1.17.0", "react-charts": "3.0.0-beta.57", "react-dom": "19.2.5", "react-error-boundary": "6.1.1", "react-flip-toolkit": "7.2.4", - "react-i18next": "16.6.6", - "react-router": "7.14.1", + "react-i18next": "17.0.4", + "react-router": "7.14.2", "react-use": "17.6.0", "react-use-draggable-scroll": "0.4.7", "remeda": "2.33.7", "remix-auth": "4.2.0", "remix-auth-oauth2": "3.4.1", - "remix-i18next": "7.4.2", + "remix-i18next": "7.5.0", "slugify": "1.6.9", "swr": "2.4.1", + "web-haptics": "0.0.6", "web-push": "3.6.7", "zod": "4.3.6" }, "devDependencies": { - "@biomejs/biome": "2.4.11", + "@biomejs/biome": "2.4.13", "@playwright/test": "1.59.1", - "@react-router/dev": "7.14.1", + "@react-router/dev": "7.14.2", "@types/better-sqlite3": "7.6.13", "@types/node": "25.6.0", "@types/node-cron": "3.0.11", @@ -106,21 +109,21 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/web-push": "3.6.4", - "@vitest/browser-playwright": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.5", + "@vitest/ui": "4.1.5", "babel-plugin-react-compiler": "19.1.0-rc.3", "cross-env": "10.1.0", "dotenv": "17.4.2", "i18next-locales-sync": "2.1.1", - "knip": "6.4.1", + "knip": "6.7.0", "ley": "0.8.1", "sql-formatter": "15.7.3", "tsx": "4.21.0", "typescript": "6.0.3", - "vite": "8.0.8", + "vite": "8.0.10", "vite-node": "6.0.0", "vite-plugin-babel": "1.6.0", - "vitest": "4.1.4", + "vitest": "4.1.5", "vitest-browser-react": "2.2.0" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bb447dc6..09bc865c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: 3.1030.0 - version: 3.1030.0 + specifier: 3.1037.0 + version: 3.1037.0 '@aws-sdk/lib-storage': - specifier: 3.1030.0 - version: 3.1030.0(@aws-sdk/client-s3@3.1030.0) + specifier: 3.1037.0 + version: 3.1037.0(@aws-sdk/client-s3@3.1037.0) '@date-fns/tz': specifier: 1.4.1 version: 1.4.1 @@ -36,17 +36,17 @@ importers: specifier: 10.4.0 version: 10.4.0 '@formatjs/intl-durationformat': - specifier: 0.10.4 - version: 0.10.4 + specifier: 0.10.5 + version: 0.10.5 '@internationalized/date': specifier: 3.12.1 version: 3.12.1 '@react-router/node': - specifier: 7.14.1 - version: 7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + specifier: 7.14.2 + version: 7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) '@react-router/serve': - specifier: 7.14.1 - version: 7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + specifier: 7.14.2 + version: 7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) '@remix-run/form-data-parser': specifier: 0.16.0 version: 0.16.0 @@ -78,20 +78,20 @@ importers: specifier: 4.0.3 version: 4.0.3 i18next: - specifier: 25.10.10 - version: 25.10.10(typescript@6.0.3) + specifier: 26.0.8 + version: 26.0.8(typescript@6.0.3) i18next-browser-languagedetector: specifier: 8.2.1 version: 8.2.1 i18next-http-backend: - specifier: 3.0.5 - version: 3.0.5 + specifier: 3.0.6 + version: 3.0.6 ics: - specifier: 3.11.0 - version: 3.11.0 + specifier: 3.12.0 + version: 3.12.0 isbot: - specifier: 5.1.38 - version: 5.1.38 + specifier: 5.1.39 + version: 5.1.39 jsoncrush: specifier: 1.1.8 version: 1.1.8 @@ -102,11 +102,11 @@ importers: specifier: 11.3.5 version: 11.3.5 lucide-react: - specifier: 1.8.0 - version: 1.8.0(react@19.2.5) + specifier: 1.11.0 + version: 1.11.0(react@19.2.5) markdown-to-jsx: - specifier: 9.7.15 - version: 9.7.15(react@19.2.5) + specifier: 9.7.16 + version: 9.7.16(react@19.2.5) nanoid: specifier: 5.1.9 version: 5.1.9 @@ -126,8 +126,11 @@ importers: specifier: 7.3.0 version: 7.3.0 partysocket: - specifier: 1.1.16 - version: 1.1.16(react@19.2.5) + specifier: 1.1.18 + version: 1.1.18(react@19.2.5) + qrcode.react: + specifier: 4.2.0 + version: 4.2.0(react@19.2.5) react: specifier: 19.2.5 version: 19.2.5 @@ -147,11 +150,11 @@ importers: specifier: 7.2.4 version: 7.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-i18next: - specifier: 16.6.6 - version: 16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + specifier: 17.0.4 + version: 17.0.4(i18next@26.0.8(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) react-router: - specifier: 7.14.1 - version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: 7.14.2 + version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-use: specifier: 17.6.0 version: 17.6.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -168,14 +171,17 @@ importers: specifier: 3.4.1 version: 3.4.1(remix-auth@4.2.0) remix-i18next: - specifier: 7.4.2 - version: 7.4.2(i18next@25.10.10(typescript@6.0.3))(react-i18next@16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + specifier: 7.5.0 + version: 7.5.0(i18next@26.0.8(typescript@6.0.3))(react-i18next@17.0.4(i18next@26.0.8(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) slugify: specifier: 1.6.9 version: 1.6.9 swr: specifier: 2.4.1 version: 2.4.1(react@19.2.5) + web-haptics: + specifier: 0.0.6 + version: 0.0.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) web-push: specifier: 3.6.7 version: 3.6.7 @@ -184,14 +190,14 @@ importers: version: 4.3.6 devDependencies: '@biomejs/biome': - specifier: 2.4.11 - version: 2.4.11 + specifier: 2.4.13 + version: 2.4.13 '@playwright/test': specifier: 1.59.1 version: 1.59.1 '@react-router/dev': - specifier: 7.14.1 - version: 7.14.1(@react-router/serve@7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3))(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + specifier: 7.14.2 + version: 7.14.2(@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3))(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(yaml@2.8.4) '@types/better-sqlite3': specifier: 7.6.13 version: 7.6.13 @@ -214,11 +220,11 @@ importers: specifier: 3.6.4 version: 3.6.4 '@vitest/browser-playwright': - specifier: 4.1.4 - version: 4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) + specifier: 4.1.5 + version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/ui': - specifier: 4.1.4 - version: 4.1.4(vitest@4.1.4) + specifier: 4.1.5 + version: 4.1.5(vitest@4.1.5) babel-plugin-react-compiler: specifier: 19.1.0-rc.3 version: 19.1.0-rc.3 @@ -232,8 +238,8 @@ importers: specifier: 2.1.1 version: 2.1.1 knip: - specifier: 6.4.1 - version: 6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + specifier: 6.7.0 + version: 6.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) ley: specifier: 0.8.1 version: 0.8.1 @@ -247,20 +253,20 @@ importers: specifier: 6.0.3 version: 6.0.3 vite: - specifier: 8.0.8 - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + specifier: 8.0.10 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) vite-node: specifier: 6.0.0 - version: 6.0.0(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + version: 6.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) vite-plugin-babel: specifier: 1.6.0 - version: 1.6.0(@babel/core@7.29.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.6.0(@babel/core@7.29.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) vitest: - specifier: 4.1.4 - version: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) vitest-browser-react: specifier: 2.2.0 - version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4) + version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5) packages: @@ -287,55 +293,55 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.1030.0': - resolution: {integrity: sha512-sgGb4ub0JXnHaXnok5td7A1KGwENFPwOrwgzvpkeWq9w16Sl7x2KhYtVl+Fdd/7LAvaEtm3HqrYtNmm2d0OXmQ==} + '@aws-sdk/client-s3@3.1037.0': + resolution: {integrity: sha512-DBmA1jAW8ST6C4srBxeL1/RLIir/d8WOm4s4mi59mGp6mBktHM59Kwb7GuURaCO60cotuce5zr0sKpMLPcBQyA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.3': - resolution: {integrity: sha512-W3aJJm2clu8OmsrwMOMnfof13O6LGnbknnZIQeSRbxjqKah2nVvkjbUBBZVhWrt08KC69H7WsINTdrxC/2SXQw==} + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} engines: {node: '>=20.0.0'} '@aws-sdk/crc64-nvme@3.972.7': resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.29': - resolution: {integrity: sha512-rf+AlUxgTeSzQ/4zoS0D+Bt7XvgpY48PnWG8Yg/N9fdMgyK2Jaqa+6tLZp4MYMIMHkGrfAxnbSeb2YLMGFMg6g==} + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.31': - resolution: {integrity: sha512-TR2/lQ3qKFj2EOrsiASzemsNEz2uzZ/SUBf48+U4Cr9a/FZlHfH/hwAeBJNBp1gMyJNxROJZhT3dn1cO+jnYfQ==} + '@aws-sdk/credential-provider-http@3.972.36': + resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.33': - resolution: {integrity: sha512-UwdbJbOrgnOxZbshaNZ4DzX35h5wQd33MNYTGzWhN3ORG9lG9KQbDX6l6tDJSAdaGTktJoZPSritmUoW1rYkRA==} + '@aws-sdk/credential-provider-ini@3.972.38': + resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.33': - resolution: {integrity: sha512-WyZuPVoDM1HGNl41eVg8HSSXIB+FGkuuK63GhDbh4TMdfWU03AciWvF/QqOVWvJtWVYaLddANJ+aUklVr2ieuw==} + '@aws-sdk/credential-provider-login@3.972.38': + resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.34': - resolution: {integrity: sha512-sPcisURibKU4x0PCWJkWF1KJYm49Cph9dCn/PAnG5FU0wq5Id3g2v7RuEWAtNlKv1Af4gUJYBVGOeNpSEEx41A==} + '@aws-sdk/credential-provider-node@3.972.39': + resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.29': - resolution: {integrity: sha512-DURisqWS3bUgiwMXTmzymVNGlcRW0FnbPZ3SZknhmxnCXm3n9idkTJ6T+Uir359KRKtJNFLRViskk8HsSVLi1w==} + '@aws-sdk/credential-provider-process@3.972.34': + resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.33': - resolution: {integrity: sha512-9y9obU4IQWru9f+NiiscUeyCe5ZmQav4FKEb1qfUNrik/C3BzBGUnHQWyPEyXjOX9cb+vx1TYx0qZBtinKdzTA==} + '@aws-sdk/credential-provider-sso@3.972.38': + resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.33': - resolution: {integrity: sha512-RazhlN0YAkna2T2p2v4YuuRlVBVRNo8V0SL+9JePTWDndEUAeOBAjYeQfAMbtDyCh120+zA0Op6V0jS4dw2+iw==} + '@aws-sdk/credential-provider-web-identity@3.972.38': + resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1030.0': - resolution: {integrity: sha512-1Hn+m1sioy3OMvF/I1uDz9QjpqcE3QSsHvz0Y0UXyMthNCpvAEvN4qO9RWBDGfVqddY1Flsp0rfvjwYP4KVr+w==} + '@aws-sdk/lib-storage@3.1037.0': + resolution: {integrity: sha512-ZFg5Vf4RKS48xTm7DfXTeR0Rvn/Fcu6YFdRygGnvhA+gW3W0WtsRqM1CzkWevYBztdUUAsZqtGbMj9Eu0OaeEg==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1030.0 + '@aws-sdk/client-s3': ^3.1037.0 '@aws-sdk/middleware-bucket-endpoint@3.972.10': resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} @@ -345,8 +351,8 @@ packages: resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.11': - resolution: {integrity: sha512-jTrJFs4SMs9xjih45+QHtU79piovA6CAlofMt4jeknN5ef9zsVEHDtuwCnEe/3eANWewa9fd6Tvc54xEPpQ3RA==} + '@aws-sdk/middleware-flexible-checksums@3.974.16': + resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-host-header@3.972.10': @@ -365,32 +371,32 @@ packages: resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.32': - resolution: {integrity: sha512-dc2O2x0V5pGJhmdQYQveUIFtMZsur7GrGuSgoKM4oQJuEcfvwnJ3sj+ip6WnxR5l6TrX5zkl4KgcgswOy3wAzQ==} + '@aws-sdk/middleware-sdk-s3@3.972.37': + resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-ssec@3.972.10': resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.33': - resolution: {integrity: sha512-mqtT3Fo7xanWMk2SbAcKLGGI/q1GHWNrExBj7cnWP2W2mkTMheXB4ntJvwPZ1UxPrQobrsv2dWFXmaOJeSOiDg==} + '@aws-sdk/middleware-user-agent@3.972.38': + resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.1': - resolution: {integrity: sha512-Afc9hc2WZs3X4Jb8dnxyuYiZsLoWRO51roTCRf497gPnAKN2WRdXANu1vaVCTzwnDMOYFXb/cYv4ZSjxqAqcKA==} + '@aws-sdk/nested-clients@3.997.6': + resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} engines: {node: '>=20.0.0'} '@aws-sdk/region-config-resolver@3.972.13': resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.20': - resolution: {integrity: sha512-MEj6DhEcaO8RgVtFCJ+xpCQnZC3Iesr09avdY75qkMQfckQULu447IegK7Rs1MCGerVBfKnJQ4q+pQq9hI5lng==} + '@aws-sdk/signature-v4-multi-region@3.996.25': + resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1034.0': - resolution: {integrity: sha512-8E+KGcD4ET0H9FXJ2/ZWbfFnQNYEkTZZYJxAs1lkdJlve1AYuqaydInIFfvNgoz5GbYtzbK8/ugsSMu5wPm6kA==} + '@aws-sdk/token-providers@3.1041.0': + resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.8': @@ -412,8 +418,8 @@ packages: '@aws-sdk/util-user-agent-browser@3.972.10': resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} - '@aws-sdk/util-user-agent-node@3.973.19': - resolution: {integrity: sha512-ZAfHjpzdbrzkAftC139JoYGfXzDh5HY+AxRzw8pGJ8cULsf+l721sKAMK8mV5NvRETaW/BwghSwQhGgoNgrxMw==} + '@aws-sdk/util-user-agent-node@3.973.24': + resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -421,8 +427,8 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.18': - resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': @@ -562,59 +568,59 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.11': - resolution: {integrity: sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==} + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.11': - resolution: {integrity: sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==} + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.11': - resolution: {integrity: sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==} + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.11': - resolution: {integrity: sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==} + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.11': - resolution: {integrity: sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==} + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.11': - resolution: {integrity: sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==} + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.11': - resolution: {integrity: sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==} + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.11': - resolution: {integrity: sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==} + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.11': - resolution: {integrity: sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==} + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -657,9 +663,15 @@ packages: resolution: {integrity: sha512-VLhlvEPDJ0Sd0pE6sAYTQkIqZCXVonaWlgRJIQQHzfjTXCadF77qqHj5NxaPSc4wCul0DJO/0MnejVqJAXUiRg==} engines: {node: '>=20.0.0'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -678,312 +690,156 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@faker-js/faker@10.4.0': resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -1006,11 +862,11 @@ packages: '@formatjs/fast-memoize@3.1.2': resolution: {integrity: sha512-vPnriihkfK0lzoQGaXq+qXH23VsYyansRTkTgo2aTG0k1NjLFyZimFVdfj4C9JkSE5dm7CEngcQ5TTc1yAyBfQ==} - '@formatjs/intl-durationformat@0.10.4': - resolution: {integrity: sha512-SUS4xT3GX43/thXTZojOXMNMB1zUIlLC+3mdyJga7evrgV4jvmgycX9bXfmMNDu7c2TC9gnXySUpUcxMghZrKg==} + '@formatjs/intl-durationformat@0.10.5': + resolution: {integrity: sha512-BGrUBBkyBtZuXwr2gOeOZvsZ9mSQ94i0O0NQIS4ebfAkBKcA4s0OlWF4eb6eQP5U4KSoXetRpvp3hHO5a/MqAg==} - '@formatjs/intl-localematcher@0.8.3': - resolution: {integrity: sha512-pHUjWb9NuhnMs8+PxQdzBtZRFJHlGhrURGAbm6Ltwl82BFajeuiIR3jblSa7ia3r62rXe/0YtVpUG3xWr5bFCA==} + '@formatjs/intl-localematcher@0.8.4': + resolution: {integrity: sha512-J51dAnynnqJdVUEXidHoIWn+qYve+yNQEgmFk9Dyfr3p0okzm+5QhQ+9QmsMz08+BeWTVpc1HadIiLfZmRYbAQ==} '@internationalized/date@3.12.1': resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} @@ -1049,17 +905,8 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} '@oslojs/asn1@1.0.0': resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} @@ -1079,138 +926,135 @@ packages: '@oslojs/jwt@0.2.0': resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} - '@oxc-parser/binding-android-arm-eabi@0.121.0': - resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} + '@oxc-parser/binding-android-arm-eabi@0.127.0': + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.121.0': - resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==} + '@oxc-parser/binding-android-arm64@0.127.0': + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.121.0': - resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==} + '@oxc-parser/binding-darwin-arm64@0.127.0': + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.121.0': - resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==} + '@oxc-parser/binding-darwin-x64@0.127.0': + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.121.0': - resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==} + '@oxc-parser/binding-freebsd-x64@0.127.0': + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': - resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': - resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==} + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.121.0': - resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==} + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.121.0': - resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': - resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': - resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.121.0': - resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.121.0': - resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.121.0': - resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.121.0': - resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} + '@oxc-parser/binding-linux-x64-musl@0.127.0': + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.121.0': - resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} + '@oxc-parser/binding-openharmony-arm64@0.127.0': + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.121.0': - resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==} - engines: {node: '>=14.0.0'} + '@oxc-parser/binding-wasm32-wasi@0.127.0': + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.121.0': - resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==} + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.121.0': - resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==} + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.121.0': - resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==} + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.121.0': - resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} - - '@oxc-project/types@0.124.0': - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} @@ -1700,14 +1544,14 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-router/dev@7.14.1': - resolution: {integrity: sha512-ZBEwods1TxqPVY2SrXDuDCfoaE5VoTMBYrfa/+3MesprY3foSo1jhin9mh4FwmXPXhhmDYKXi2z5UR+oMj8Qjg==} + '@react-router/dev@7.14.2': + resolution: {integrity: sha512-lU88Ls4iC78RdPOKkER54+hlsHzzS8WSZrf2/cGQumbIN2A5WvO0LDyv72cdJmLWujgZ9rpNoGzmqWINssShGQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - '@react-router/serve': ^7.14.1 + '@react-router/serve': ^7.14.2 '@vitejs/plugin-rsc': ~0.5.21 - react-router: ^7.14.1 + react-router: ^7.14.2 react-server-dom-webpack: ^19.2.3 typescript: ^5.1.0 || ^6.0.0 vite: ^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1724,33 +1568,33 @@ packages: wrangler: optional: true - '@react-router/express@7.14.1': - resolution: {integrity: sha512-XX/R+/JIIbwTfaXHz1WAJbiPfkd56y7PN9Czg7h6Tvos9TZlmMXmRhxWKRdzfsa8Lp8sq42JjKOBCEEPyH4V1Q==} + '@react-router/express@7.14.2': + resolution: {integrity: sha512-IYs61kHfMWsJk/ju4Ts4hw7wblZecfXuIvqQPKEaz+gwpkJMSWDzhPpgmC16EnmBQkXPqMVpsjvNxA/d9p9ehg==} engines: {node: '>=20.0.0'} peerDependencies: express: ^4.17.1 || ^5 - react-router: 7.14.1 + react-router: 7.14.2 typescript: ^5.1.0 || ^6.0.0 peerDependenciesMeta: typescript: optional: true - '@react-router/node@7.14.1': - resolution: {integrity: sha512-SthTjCwW7otzEAcZwF0RAPMRrDT47B4qHDxZM45rM5K1Gp86ANK/xlXF+DgpLq9qKZf9FbKzxS9hT7FqDeBAOg==} + '@react-router/node@7.14.2': + resolution: {integrity: sha512-8zxVfgKOXjk0k8YxSBDTFyNAuVdr+og1wFbQpmJJOxo7ObxfI81EbHenyyxGvFiw77rNFLS9Dqgnv5xZgHZfCw==} engines: {node: '>=20.0.0'} peerDependencies: - react-router: 7.14.1 + react-router: 7.14.2 typescript: ^5.1.0 || ^6.0.0 peerDependenciesMeta: typescript: optional: true - '@react-router/serve@7.14.1': - resolution: {integrity: sha512-3oSNEQqU4ekIQTMqc7c9MJMHzSUAl4knG5mF9+1HaLqvUaYAfZPidqd4JWQKeYwe6Tw6fa79lcvUXqfCSXiEUg==} + '@react-router/serve@7.14.2': + resolution: {integrity: sha512-Rh/Mrd9+Jkf+IOd7beEccCfTDavOQRpkk0TLwLFK60dv0yUIyOTIaKxC7W6I0WMrgAjhUL09JxfMsoz2vtYhTg==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - react-router: 7.14.1 + react-router: 7.14.2 '@react-types/shared@3.34.0': resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==} @@ -1772,196 +1616,196 @@ packages: '@remix-run/node-fetch-server@0.13.0': resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} - '@rolldown/binding-android-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] libc: [glibc] @@ -1972,45 +1816,39 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] @@ -2026,8 +1864,8 @@ packages: resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.16': - resolution: {integrity: sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==} + '@smithy/core@3.23.17': + resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.2.14': @@ -2090,16 +1928,16 @@ packages: resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.31': - resolution: {integrity: sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw==} + '@smithy/middleware-endpoint@4.4.32': + resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.5.4': - resolution: {integrity: sha512-/z7nIFK+ZRW3Ie/l3NEVGdy34LvmEOzBrtBAvgWZ/4PrKX0xP3kWm8pkfcwUk523SqxZhdbQP9JSXgjF77Uhpw==} + '@smithy/middleware-retry@4.5.7': + resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.19': - resolution: {integrity: sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw==} + '@smithy/middleware-serde@4.2.20': + resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} engines: {node: '>=18.0.0'} '@smithy/middleware-stack@4.2.14': @@ -2110,8 +1948,8 @@ packages: resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.6.0': - resolution: {integrity: sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==} + '@smithy/node-http-handler@4.6.1': + resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} engines: {node: '>=18.0.0'} '@smithy/property-provider@4.2.14': @@ -2130,8 +1968,8 @@ packages: resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.3.0': - resolution: {integrity: sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==} + '@smithy/service-error-classification@4.3.1': + resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} engines: {node: '>=18.0.0'} '@smithy/shared-ini-file-loader@4.4.9': @@ -2142,8 +1980,8 @@ packages: resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.12': - resolution: {integrity: sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==} + '@smithy/smithy-client@4.12.13': + resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} engines: {node: '>=18.0.0'} '@smithy/types@4.14.1': @@ -2178,12 +2016,12 @@ packages: resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.48': - resolution: {integrity: sha512-hxVRVPYaRDWa6YQdse1aWX1qrksmLsvNyGBKdc32q4jFzSjxYVNWfstknAfR228TnzS4tzgswXRuYIbhXBuXFQ==} + '@smithy/util-defaults-mode-browser@4.3.49': + resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.53': - resolution: {integrity: sha512-ybgCk+9JdBq8pYC8Y6U5fjyS8e4sboyAShetxPNL0rRBtaVl56GSFAxsolVBIea1tXR4LPIzL8i6xqmcf0+DCQ==} + '@smithy/util-defaults-mode-node@4.2.54': + resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} engines: {node: '>=18.0.0'} '@smithy/util-endpoints@3.4.2': @@ -2198,12 +2036,12 @@ packages: resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.3.3': - resolution: {integrity: sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A==} + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.24': - resolution: {integrity: sha512-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg==} + '@smithy/util-stream@4.5.25': + resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} engines: {node: '>=18.0.0'} '@smithy/util-uri-escape@4.2.2': @@ -2218,8 +2056,8 @@ packages: resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} engines: {node: '>=18.0.0'} - '@smithy/util-waiter@4.2.16': - resolution: {integrity: sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ==} + '@smithy/util-waiter@4.3.0': + resolution: {integrity: sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==} engines: {node: '>=18.0.0'} '@smithy/uuid@1.1.2': @@ -2412,8 +2250,8 @@ packages: '@tldraw/validate@3.12.1': resolution: {integrity: sha512-WhQRuUy7VYBxdgz49FvtKwoNgKWT99QKRd29/Q4kaftG6PKjYHbojxt0dxXjGI03Lb5hhDG4uYSMK22qo0MLwg==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -2511,22 +2349,22 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vitest/browser-playwright@4.1.4': - resolution: {integrity: sha512-q3PchVhZINX23Pv+RERgAtDlp6wzVkID/smOPnZ5YGWpeWUe3jMNYppeVh15j4il3G7JIJty1d1Kicpm0HSMig==} + '@vitest/browser-playwright@4.1.5': + resolution: {integrity: sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==} peerDependencies: playwright: '*' - vitest: 4.1.4 + vitest: 4.1.5 - '@vitest/browser@4.1.4': - resolution: {integrity: sha512-TrNaY/yVOwxtrxNsDUC/wQ56xSwplpytTeRAqF/197xV/ZddxxulBsxR6TrhVMyniJmp9in8d5u0AcDaNRY30w==} + '@vitest/browser@4.1.5': + resolution: {integrity: sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==} peerDependencies: - vitest: 4.1.4 + vitest: 4.1.5 - '@vitest/expect@4.1.4': - resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} - '@vitest/mocker@4.1.4': - resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2536,25 +2374,25 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.4': - resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/runner@4.1.4': - resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} - '@vitest/snapshot@4.1.4': - resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} - '@vitest/spy@4.1.4': - resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} - '@vitest/ui@4.1.4': - resolution: {integrity: sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==} + '@vitest/ui@4.1.5': + resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==} peerDependencies: - vitest: 4.1.4 + vitest: 4.1.5 - '@vitest/utils@4.1.4': - resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} @@ -2622,8 +2460,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.20: - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} engines: {node: '>=6.0.0'} hasBin: true @@ -2647,19 +2485,15 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2702,8 +2536,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001790: - resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2927,8 +2761,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.343: - resolution: {integrity: sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==} + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2961,6 +2795,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2970,11 +2807,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3038,26 +2870,19 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-shallow-equal@1.0.0: resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.1.7: + resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} - fast-xml-parser@5.5.8: - resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} hasBin: true fastest-stable-stringify@2.0.2: resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -3076,10 +2901,6 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -3166,13 +2987,12 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3227,16 +3047,16 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next-http-backend@3.0.5: - resolution: {integrity: sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==} + i18next-http-backend@3.0.6: + resolution: {integrity: sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==} i18next-locales-sync@2.1.1: resolution: {integrity: sha512-NC/Zw0kKT+VULSh6zzDSxvqDUS9BHY6tk2cj93qQ/yaNuKc3hg4VlJFDeuaxdovY1/LobcdQCAc6g1U03qe2SQ==} engines: {node: '>=12'} hasBin: true - i18next@25.10.10: - resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} + i18next@26.0.8: + resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -3247,8 +3067,8 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - ics@3.11.0: - resolution: {integrity: sha512-uDB3GEOYRSZL2BgxLZkHUDwcHqjC36gGuKBrFatl7BtMNQKzO2rXkCj0zTl/QyP9lfyAEp2WZU2XBZciaLJZZw==} + ics@3.12.0: + resolution: {integrity: sha512-BBPiZ/TbUyZrxui7SHKGOa/n2HUxaW9ucn7cw47NW9OSKe5tItYxIdN7Uczti5nKURmfwVvYi2aLigCJGH1+cA==} idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -3291,10 +3111,6 @@ packages: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -3303,14 +3119,6 @@ packages: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} @@ -3326,8 +3134,8 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isbot@5.1.38: - resolution: {integrity: sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA==} + isbot@5.1.39: + resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} engines: {node: '>=18'} isexe@2.0.0: @@ -3356,6 +3164,11 @@ packages: engines: {node: '>=6'} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3381,8 +3194,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - knip@6.4.1: - resolution: {integrity: sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==} + knip@6.7.0: + resolution: {integrity: sha512-ckL51NDH1YJxnv1kNB0iUdDngB4f/e9Igz8uIqYfmNDoyOFmmk1V0WFv3LQ7/hzC63b2Z9X41gGUE9eOWrZpaA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3485,8 +3298,8 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -3499,8 +3312,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.8.0: - resolution: {integrity: sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==} + lucide-react@1.11.0: + resolution: {integrity: sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3515,8 +3328,8 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true - markdown-to-jsx@9.7.15: - resolution: {integrity: sha512-e+zu57JYDDEiCYNSC8ohEeihBcAs73QdS++qd9Ffmeo6EU+yBFsfbgntkaT7lU2pRU6W4Yqh8a0cvpbmad7wHA==} + markdown-to-jsx@9.7.16: + resolution: {integrity: sha512-+LEgOlYfUEB9i2Oaxasec9H2HytB1+SQcgwFmQiNTKwe8cQ2E9bDNgePGq6ChIycMxtpcEY0g44aQ3uJoMw8eg==} engines: {node: '>= 18'} peerDependencies: react: '>= 16.0.0' @@ -3550,18 +3363,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3623,8 +3428,8 @@ packages: react: '*' react-dom: '*' - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3669,8 +3474,8 @@ packages: encoding: optional: true - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} @@ -3708,8 +3513,8 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - oxc-parser@0.121.0: - resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} + oxc-parser@0.127.0: + resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.19.1: @@ -3727,8 +3532,8 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - partysocket@1.1.16: - resolution: {integrity: sha512-d7xFv+ZC7x0p/DAHWJ5FhxQhimIx+ucyZY+kxL0cKddLBmK9c4p2tEA/L+dOOrWm6EYrRwrBjKQV0uSzOY9x1w==} + partysocket@1.1.18: + resolution: {integrity: sha512-SyuvH9VavWOSa14v6dYdp3yfSUDII4BQB1+TkGOFBkjfZKjnDBiba4fhdhwBlqGBkqw4ea3gTA1DYhSffX24Wg==} peerDependencies: react: '>=17' peerDependenciesMeta: @@ -3755,16 +3560,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} @@ -3784,8 +3585,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.13: + resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -3794,8 +3595,8 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -3877,18 +3678,24 @@ packages: punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + querystring@0.2.0: resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} engines: {node: '>=0.4.x'} deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - railroad-diagrams@1.0.0: resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} @@ -3946,10 +3753,10 @@ packages: react: '>= 16.x' react-dom: '>= 16.x' - react-i18next@16.6.6: - resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==} + react-i18next@17.0.4: + resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} peerDependencies: - i18next: '>= 25.10.9' + i18next: '>= 26.0.1' react: '>= 16.8.0' react-dom: '*' react-native: '*' @@ -3989,8 +3796,8 @@ packages: '@types/react': optional: true - react-router@7.14.1: - resolution: {integrity: sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==} + react-router@7.14.2: + resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -4060,13 +3867,13 @@ packages: resolution: {integrity: sha512-3LSfWEvSgG2CgbG/p4ge5hbV8tTXWNnnYIGbTr9oSSiHz9dD7wh6S0MEyo3pwh7MlKezB2WIlevGeyqUZykk7g==} engines: {node: '>=20.0.0'} - remix-i18next@7.4.2: - resolution: {integrity: sha512-k8IUyft4hXxJ6HNE+K2l29DXCvMywZJOC7jOw99Bb/Gh87i2umLiOGKV1Aiub6E+QflkTkd9/JgS2Jo5Ngw2VA==} + remix-i18next@7.5.0: + resolution: {integrity: sha512-7vAwnNkAXzr/YQwFkV1FHrY+3utgGrDwn7oP/aM2gFHrwbGT0+uVtuUx3USpTrJvhzAN+2YU/mnmgyBi9EhYyw==} engines: {node: '>=20.0.0'} peerDependencies: - i18next: ^24.0.0 || ^25.0.0 + i18next: ^24.0.0 || ^25.0.0 || ^26.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-i18next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react-i18next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 react-router: ^7.0.0 require-directory@2.1.1: @@ -4083,17 +3890,13 @@ packages: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rolldown@1.0.0-rc.15: - resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4103,9 +3906,6 @@ packages: rtl-css-js@1.16.1: resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - runes2@1.1.4: resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==} @@ -4179,8 +3979,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -4261,8 +4061,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} @@ -4322,8 +4122,8 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} tinyglobby@0.2.16: @@ -4343,10 +4143,6 @@ packages: react: ^18.2.0 || ^19.0.0 react-dom: ^18.2.0 || ^19.0.0 - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} @@ -4404,8 +4200,8 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - unbash@2.2.0: - resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} + unbash@3.0.0: + resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} undici-types@7.19.2: @@ -4465,6 +4261,7 @@ packages: uuid@8.0.0: resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.3.1: @@ -4535,8 +4332,8 @@ packages: yaml: optional: true - vite@8.0.8: - resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4592,20 +4389,20 @@ packages: '@types/react-dom': optional: true - vitest@4.1.4: - resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.4 - '@vitest/browser-preview': 4.1.4 - '@vitest/browser-webdriverio': 4.1.4 - '@vitest/coverage-istanbul': 4.1.4 - '@vitest/coverage-v8': 4.1.4 - '@vitest/ui': 4.1.4 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4644,6 +4441,23 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + web-haptics@0.0.6: + resolution: {integrity: sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + svelte: '>=4' + vue: '>=3' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} @@ -4703,8 +4517,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true @@ -4775,31 +4589,31 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1030.0': + '@aws-sdk/client-s3@3.1037.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.3 - '@aws-sdk/credential-provider-node': 3.972.34 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 '@aws-sdk/middleware-bucket-endpoint': 3.972.10 '@aws-sdk/middleware-expect-continue': 3.972.10 - '@aws-sdk/middleware-flexible-checksums': 3.974.11 + '@aws-sdk/middleware-flexible-checksums': 3.974.16 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-location-constraint': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-sdk-s3': 3.972.32 + '@aws-sdk/middleware-sdk-s3': 3.972.37 '@aws-sdk/middleware-ssec': 3.972.10 - '@aws-sdk/middleware-user-agent': 3.972.33 + '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.20 + '@aws-sdk/signature-v4-multi-region': 3.996.25 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.19 + '@aws-sdk/util-user-agent-node': 3.973.24 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.16 + '@smithy/core': 3.23.17 '@smithy/eventstream-serde-browser': 4.2.14 '@smithy/eventstream-serde-config-resolver': 4.3.14 '@smithy/eventstream-serde-node': 4.2.14 @@ -4810,45 +4624,45 @@ snapshots: '@smithy/invalid-dependency': 4.2.14 '@smithy/md5-js': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.0 + '@smithy/node-http-handler': 4.6.1 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 - '@smithy/util-stream': 4.5.24 + '@smithy/util-retry': 4.3.8 + '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 - '@smithy/util-waiter': 4.2.16 + '@smithy/util-waiter': 4.3.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.974.3': + '@aws-sdk/core@3.974.8': dependencies: '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.18 - '@smithy/core': 3.23.16 + '@aws-sdk/xml-builder': 3.972.22 + '@smithy/core': 3.23.17 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -4857,37 +4671,37 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.29': + '@aws-sdk/credential-provider-env@3.972.34': dependencies: - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.31': + '@aws-sdk/credential-provider-http@3.972.36': dependencies: - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/types': 3.973.8 '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 + '@smithy/node-http-handler': 4.6.1 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.24 + '@smithy/util-stream': 4.5.25 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.33': + '@aws-sdk/credential-provider-ini@3.972.38': dependencies: - '@aws-sdk/core': 3.974.3 - '@aws-sdk/credential-provider-env': 3.972.29 - '@aws-sdk/credential-provider-http': 3.972.31 - '@aws-sdk/credential-provider-login': 3.972.33 - '@aws-sdk/credential-provider-process': 3.972.29 - '@aws-sdk/credential-provider-sso': 3.972.33 - '@aws-sdk/credential-provider-web-identity': 3.972.33 - '@aws-sdk/nested-clients': 3.997.1 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-login': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/nested-clients': 3.997.6 '@aws-sdk/types': 3.973.8 '@smithy/credential-provider-imds': 4.2.14 '@smithy/property-provider': 4.2.14 @@ -4897,10 +4711,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.33': + '@aws-sdk/credential-provider-login@3.972.38': dependencies: - '@aws-sdk/core': 3.974.3 - '@aws-sdk/nested-clients': 3.997.1 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.3.14 @@ -4910,14 +4724,14 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.34': + '@aws-sdk/credential-provider-node@3.972.39': dependencies: - '@aws-sdk/credential-provider-env': 3.972.29 - '@aws-sdk/credential-provider-http': 3.972.31 - '@aws-sdk/credential-provider-ini': 3.972.33 - '@aws-sdk/credential-provider-process': 3.972.29 - '@aws-sdk/credential-provider-sso': 3.972.33 - '@aws-sdk/credential-provider-web-identity': 3.972.33 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-ini': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 '@aws-sdk/types': 3.973.8 '@smithy/credential-provider-imds': 4.2.14 '@smithy/property-provider': 4.2.14 @@ -4927,20 +4741,20 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.29': + '@aws-sdk/credential-provider-process@3.972.34': dependencies: - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.33': + '@aws-sdk/credential-provider-sso@3.972.38': dependencies: - '@aws-sdk/core': 3.974.3 - '@aws-sdk/nested-clients': 3.997.1 - '@aws-sdk/token-providers': 3.1034.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/token-providers': 3.1041.0 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -4949,10 +4763,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.33': + '@aws-sdk/credential-provider-web-identity@3.972.38': dependencies: - '@aws-sdk/core': 3.974.3 - '@aws-sdk/nested-clients': 3.997.1 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -4961,12 +4775,12 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/lib-storage@3.1030.0(@aws-sdk/client-s3@3.1030.0)': + '@aws-sdk/lib-storage@3.1037.0(@aws-sdk/client-s3@3.1037.0)': dependencies: - '@aws-sdk/client-s3': 3.1030.0 - '@smithy/middleware-endpoint': 4.4.31 + '@aws-sdk/client-s3': 3.1037.0 + '@smithy/middleware-endpoint': 4.4.32 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 buffer: 5.6.0 events: 3.3.0 @@ -4990,12 +4804,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.11': + '@aws-sdk/middleware-flexible-checksums@3.974.16': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/crc64-nvme': 3.972.7 '@aws-sdk/types': 3.973.8 '@smithy/is-array-buffer': 4.2.2 @@ -5003,7 +4817,7 @@ snapshots: '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.24 + '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -5034,20 +4848,20 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.32': + '@aws-sdk/middleware-sdk-s3@3.972.37': dependencies: - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.16 + '@smithy/core': 3.23.17 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/util-config-provider': 4.2.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.24 + '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -5057,56 +4871,56 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.33': + '@aws-sdk/middleware-user-agent@3.972.38': dependencies: - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 - '@smithy/core': 3.23.16 + '@smithy/core': 3.23.17 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.3.8 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.1': + '@aws-sdk/nested-clients@3.997.6': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.3 + '@aws-sdk/core': 3.974.8 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.33 + '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.20 + '@aws-sdk/signature-v4-multi-region': 3.996.25 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.19 + '@aws-sdk/util-user-agent-node': 3.973.24 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.16 + '@smithy/core': 3.23.17 '@smithy/fetch-http-handler': 5.3.17 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.0 + '@smithy/node-http-handler': 4.6.1 '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -5120,19 +4934,19 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.20': + '@aws-sdk/signature-v4-multi-region@3.996.25': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.32 + '@aws-sdk/middleware-sdk-s3': 3.972.37 '@aws-sdk/types': 3.973.8 '@smithy/protocol-http': 5.3.14 '@smithy/signature-v4': 5.3.14 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1034.0': + '@aws-sdk/token-providers@3.1041.0': dependencies: - '@aws-sdk/core': 3.974.3 - '@aws-sdk/nested-clients': 3.997.1 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 '@aws-sdk/types': 3.973.8 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -5169,19 +4983,20 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.19': + '@aws-sdk/util-user-agent-node@3.973.24': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.33 + '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/types': 3.973.8 '@smithy/node-config-provider': 4.3.14 '@smithy/types': 4.14.1 '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.18': + '@aws-sdk/xml-builder@3.972.22': dependencies: + '@nodable/entities': 2.1.0 '@smithy/types': 4.14.1 - fast-xml-parser: 5.5.8 + fast-xml-parser: 5.7.2 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} @@ -5220,7 +5035,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.0.2 + jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.3': dependencies: @@ -5230,7 +5045,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -5374,39 +5189,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@biomejs/biome@2.4.11': + '@biomejs/biome@2.4.13': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.11 - '@biomejs/cli-darwin-x64': 2.4.11 - '@biomejs/cli-linux-arm64': 2.4.11 - '@biomejs/cli-linux-arm64-musl': 2.4.11 - '@biomejs/cli-linux-x64': 2.4.11 - '@biomejs/cli-linux-x64-musl': 2.4.11 - '@biomejs/cli-win32-arm64': 2.4.11 - '@biomejs/cli-win32-x64': 2.4.11 + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 - '@biomejs/cli-darwin-arm64@2.4.11': + '@biomejs/cli-darwin-arm64@2.4.13': optional: true - '@biomejs/cli-darwin-x64@2.4.11': + '@biomejs/cli-darwin-x64@2.4.13': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.11': + '@biomejs/cli-linux-arm64-musl@2.4.13': optional: true - '@biomejs/cli-linux-arm64@2.4.11': + '@biomejs/cli-linux-arm64@2.4.13': optional: true - '@biomejs/cli-linux-x64-musl@2.4.11': + '@biomejs/cli-linux-x64-musl@2.4.13': optional: true - '@biomejs/cli-linux-x64@2.4.11': + '@biomejs/cli-linux-x64@2.4.13': optional: true - '@biomejs/cli-win32-arm64@2.4.11': + '@biomejs/cli-win32-arm64@2.4.13': optional: true - '@biomejs/cli-win32-x64@2.4.11': + '@biomejs/cli-win32-x64@2.4.13': optional: true '@blazediff/core@1.9.1': {} @@ -5447,12 +5262,23 @@ snapshots: '@edgefirst-dev/data@0.0.4': {} + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -5470,159 +5296,81 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-x64@0.27.4': optional: true - '@esbuild/win32-x64@0.27.7': - optional: true - '@faker-js/faker@10.4.0': {} '@floating-ui/core@1.7.5': @@ -5644,11 +5392,11 @@ snapshots: '@formatjs/fast-memoize@3.1.2': {} - '@formatjs/intl-durationformat@0.10.4': + '@formatjs/intl-durationformat@0.10.5': dependencies: - '@formatjs/intl-localematcher': 0.8.3 + '@formatjs/intl-localematcher': 0.8.4 - '@formatjs/intl-localematcher@0.8.3': + '@formatjs/intl-localematcher@0.8.4': dependencies: '@formatjs/fast-memoize': 3.1.2 @@ -5687,24 +5435,21 @@ snapshots: '@mjackson/node-fetch-server@0.2.0': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@nodable/entities@2.1.0': {} '@oslojs/asn1@1.0.0': dependencies: @@ -5725,74 +5470,71 @@ snapshots: dependencies: '@oslojs/encoding': 0.4.1 - '@oxc-parser/binding-android-arm-eabi@0.121.0': + '@oxc-parser/binding-android-arm-eabi@0.127.0': optional: true - '@oxc-parser/binding-android-arm64@0.121.0': + '@oxc-parser/binding-android-arm64@0.127.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.121.0': + '@oxc-parser/binding-darwin-arm64@0.127.0': optional: true - '@oxc-parser/binding-darwin-x64@0.121.0': + '@oxc-parser/binding-darwin-x64@0.127.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.121.0': + '@oxc-parser/binding-freebsd-x64@0.127.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.121.0': + '@oxc-parser/binding-linux-arm64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.121.0': + '@oxc-parser/binding-linux-x64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.121.0': + '@oxc-parser/binding-linux-x64-musl@0.127.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.121.0': + '@oxc-parser/binding-openharmony-arm64@0.127.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@oxc-parser/binding-wasm32-wasi@0.127.0': dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.121.0': + '@oxc-parser/binding-win32-x64-msvc@0.127.0': optional: true - '@oxc-project/types@0.121.0': {} - - '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.127.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -5842,9 +5584,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -6236,7 +5978,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@7.14.1(@react-router/serve@7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3))(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': + '@react-router/dev@7.14.2(@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3))(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.21.0)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(yaml@2.8.4)': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -6245,7 +5987,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@react-router/node': 7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + '@react-router/node': 7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) '@remix-run/node-fetch-server': 0.13.0 arg: 5.0.2 babel-dead-code-elimination: 1.0.12 @@ -6253,23 +5995,23 @@ snapshots: dedent: 1.7.2 es-module-lexer: 1.7.0 exit-hook: 2.2.1 - isbot: 5.1.38 + isbot: 5.1.39 jsesc: 3.0.2 - lodash: 4.18.1 + lodash: 4.17.23 p-map: 7.0.4 pathe: 1.1.2 picocolors: 1.1.1 - pkg-types: 2.3.0 - prettier: 3.8.3 + pkg-types: 2.3.1 + prettier: 3.8.1 react-refresh: 0.14.2 - react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) semver: 7.7.4 tinyglobby: 0.2.16 valibot: 1.3.1(typescript@6.0.3) - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) optionalDependencies: - '@react-router/serve': 7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + '@react-router/serve': 7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - '@types/node' @@ -6286,31 +6028,31 @@ snapshots: - tsx - yaml - '@react-router/express@7.14.1(express@4.22.1)(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': + '@react-router/express@7.14.2(express@4.22.1)(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': dependencies: - '@react-router/node': 7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + '@react-router/node': 7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) express: 4.22.1 - react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: typescript: 6.0.3 - '@react-router/node@7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': + '@react-router/node@7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': dependencies: '@mjackson/node-fetch-server': 0.2.0 - react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: typescript: 6.0.3 - '@react-router/serve@7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': + '@react-router/serve@7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': dependencies: '@mjackson/node-fetch-server': 0.2.0 - '@react-router/express': 7.14.1(express@4.22.1)(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) - '@react-router/node': 7.14.1(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + '@react-router/express': 7.14.2(express@4.22.1)(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + '@react-router/node': 7.14.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) compression: 1.8.1 express: 4.22.1 get-port: 5.1.1 morgan: 1.10.1 - react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) source-map-support: 0.5.21 transitivePeerDependencies: - supports-color @@ -6334,133 +6076,130 @@ snapshots: '@remix-run/node-fetch-server@0.13.0': {} - '@rolldown/binding-android-arm64@1.0.0-rc.15': + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.15': + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true - '@rolldown/pluginutils@1.0.0-rc.15': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} - '@rollup/rollup-android-arm-eabi@4.60.2': + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true - '@rollup/rollup-android-arm64@4.60.2': + '@rollup/rollup-android-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-arm64@4.60.2': + '@rollup/rollup-darwin-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-x64@4.60.2': + '@rollup/rollup-darwin-x64@4.60.0': optional: true - '@rollup/rollup-freebsd-arm64@4.60.2': + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true - '@rollup/rollup-freebsd-x64@4.60.2': + '@rollup/rollup-freebsd-x64@4.60.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.2': + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.2': + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.2': + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.2': + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.2': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.2': + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.2': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.2': + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.2': + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.2': + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.2': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.60.2': + '@rollup/rollup-openbsd-x64@4.60.0': optional: true - '@rollup/rollup-openbsd-x64@4.60.2': + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true - '@rollup/rollup-openharmony-arm64@4.60.2': + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.2': + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.2': + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.2': + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true '@smithy/chunked-blob-reader-native@4.2.3': @@ -6481,7 +6220,7 @@ snapshots: '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/core@3.23.16': + '@smithy/core@3.23.17': dependencies: '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 @@ -6489,7 +6228,7 @@ snapshots: '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.24 + '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 '@smithy/uuid': 1.1.2 tslib: 2.8.1 @@ -6585,10 +6324,10 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.31': + '@smithy/middleware-endpoint@4.4.32': dependencies: - '@smithy/core': 3.23.16 - '@smithy/middleware-serde': 4.2.19 + '@smithy/core': 3.23.17 + '@smithy/middleware-serde': 4.2.20 '@smithy/node-config-provider': 4.3.14 '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.1 @@ -6596,22 +6335,22 @@ snapshots: '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/middleware-retry@4.5.4': + '@smithy/middleware-retry@4.5.7': dependencies: - '@smithy/core': 3.23.16 + '@smithy/core': 3.23.17 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 - '@smithy/service-error-classification': 4.3.0 - '@smithy/smithy-client': 4.12.12 + '@smithy/service-error-classification': 4.3.1 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.3.8 '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.19': + '@smithy/middleware-serde@4.2.20': dependencies: - '@smithy/core': 3.23.16 + '@smithy/core': 3.23.17 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -6628,7 +6367,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.6.0': + '@smithy/node-http-handler@4.6.1': dependencies: '@smithy/protocol-http': 5.3.14 '@smithy/querystring-builder': 4.2.14 @@ -6656,7 +6395,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/service-error-classification@4.3.0': + '@smithy/service-error-classification@4.3.1': dependencies: '@smithy/types': 4.14.1 @@ -6676,14 +6415,14 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/smithy-client@4.12.12': + '@smithy/smithy-client@4.12.13': dependencies: - '@smithy/core': 3.23.16 - '@smithy/middleware-endpoint': 4.4.31 + '@smithy/core': 3.23.17 + '@smithy/middleware-endpoint': 4.4.32 '@smithy/middleware-stack': 4.2.14 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.24 + '@smithy/util-stream': 4.5.25 tslib: 2.8.1 '@smithy/types@4.14.1': @@ -6724,20 +6463,20 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.48': + '@smithy/util-defaults-mode-browser@4.3.49': dependencies: '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.53': + '@smithy/util-defaults-mode-node@4.2.54': dependencies: '@smithy/config-resolver': 4.4.17 '@smithy/credential-provider-imds': 4.2.14 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -6756,16 +6495,16 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-retry@4.3.3': + '@smithy/util-retry@4.3.8': dependencies: - '@smithy/service-error-classification': 4.3.0 + '@smithy/service-error-classification': 4.3.1 '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.24': + '@smithy/util-stream@4.5.25': dependencies: '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 + '@smithy/node-http-handler': 4.6.1 '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 '@smithy/util-buffer-from': 4.2.2 @@ -6787,7 +6526,7 @@ snapshots: '@smithy/util-buffer-from': 4.2.2 tslib: 2.8.1 - '@smithy/util-waiter@4.2.16': + '@smithy/util-waiter@4.3.0': dependencies: '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -7034,7 +6773,7 @@ snapshots: dependencies: '@tldraw/utils': 3.12.1 - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true @@ -7130,29 +6869,29 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.5 - '@vitest/browser-playwright@4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': + '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: - '@vitest/browser': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) playwright: 1.59.1 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': + '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/utils': 4.1.4 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -7160,55 +6899,55 @@ snapshots: - utf-8-validate - vite - '@vitest/expect@4.1.4': + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: - '@vitest/spy': 4.1.4 + '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) - '@vitest/pretty-format@4.1.4': + '@vitest/pretty-format@4.1.5': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.4': + '@vitest/runner@4.1.5': dependencies: - '@vitest/utils': 4.1.4 + '@vitest/utils': 4.1.5 pathe: 2.0.3 - '@vitest/snapshot@4.1.4': + '@vitest/snapshot@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.4 - '@vitest/utils': 4.1.4 + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.4': {} + '@vitest/spy@4.1.5': {} - '@vitest/ui@4.1.4(vitest@4.1.4)': + '@vitest/ui@4.1.5(vitest@4.1.5)': dependencies: - '@vitest/utils': 4.1.4 + '@vitest/utils': 4.1.5 fflate: 0.8.2 flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) - '@vitest/utils@4.1.4': + '@vitest/utils@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.4 + '@vitest/pretty-format': 4.1.5 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -7290,7 +7029,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.20: {} + baseline-browser-mapping@2.10.11: {} basic-auth@2.0.1: dependencies: @@ -7315,7 +7054,7 @@ snapshots: bn.js@4.12.3: {} - body-parser@1.20.4: + body-parser@1.20.5: dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -7325,7 +7064,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.1 raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 @@ -7334,17 +7073,13 @@ snapshots: bowser@2.14.1: {} - braces@3.0.3: + browserslist@4.28.1: dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001790 - electron-to-chromium: 1.5.343 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.328 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) buffer-equal-constant-time@1.0.1: {} @@ -7389,7 +7124,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001790: {} + caniuse-lite@1.0.30001781: {} chai@6.2.2: {} @@ -7593,7 +7328,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.343: {} + electron-to-chromium@1.5.328: {} emoji-regex@8.0.0: {} @@ -7617,6 +7352,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -7650,35 +7387,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -7711,7 +7419,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.4 + body-parser: 1.20.5 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 @@ -7751,32 +7459,21 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-shallow-equal@1.0.0: {} - fast-xml-builder@1.1.5: + fast-xml-builder@1.1.7: dependencies: path-expression-matcher: 1.5.0 - fast-xml-parser@5.5.8: + fast-xml-parser@5.7.2: dependencies: - fast-xml-builder: 1.1.5 + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.7 path-expression-matcher: 1.5.0 strnum: 2.2.3 fastest-stable-stringify@2.0.2: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -7789,10 +7486,6 @@ snapshots: file-uri-to-path@1.0.0: {} - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -7875,11 +7568,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-from-package@0.0.0: {} - - glob-parent@5.1.2: + get-tsconfig@4.14.0: dependencies: - is-glob: 4.0.3 + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} gopd@1.2.0: {} @@ -7937,7 +7630,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next-http-backend@3.0.5: + i18next-http-backend@3.0.6: dependencies: cross-fetch: 4.1.0 transitivePeerDependencies: @@ -7951,9 +7644,7 @@ snapshots: picomatch: 4.0.4 yargs: 17.7.2 - i18next@25.10.10(typescript@6.0.3): - dependencies: - '@babel/runtime': 7.29.2 + i18next@26.0.8(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -7961,9 +7652,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ics@3.11.0: + ics@3.12.0: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 runes2: 1.1.4 yup: 1.7.1 @@ -7996,8 +7687,6 @@ snapshots: is-extendable@0.1.1: {} - is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} is-generator-function@1.1.2: @@ -8008,12 +7697,6 @@ snapshots: has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - is-plain-object@5.0.0: {} is-regex@1.2.1: @@ -8029,7 +7712,7 @@ snapshots: isarray@1.0.0: {} - isbot@5.1.38: {} + isbot@5.1.39: {} isexe@2.0.0: {} @@ -8048,6 +7731,8 @@ snapshots: jsesc@3.0.2: {} + jsesc@3.1.0: {} + json5@2.2.3: {} jsoncrush@1.1.8: {} @@ -8073,22 +7758,21 @@ snapshots: kleur@4.1.5: {} - knip@6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + knip@6.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: - '@nodelib/fs.walk': 1.2.8 - fast-glob: 3.3.3 + fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - picocolors: 1.1.1 + oxc-parser: 0.127.0 + oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 - unbash: 2.2.0 - yaml: 2.8.3 + tinyglobby: 0.2.16 + unbash: 3.0.0 + yaml: 2.8.4 zod: 4.3.6 transitivePeerDependencies: - '@emnapi/core' @@ -8164,7 +7848,7 @@ snapshots: lodash.uniq@4.5.0: {} - lodash@4.18.1: {} + lodash@4.17.23: {} loose-envify@1.4.0: dependencies: @@ -8176,7 +7860,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.8.0(react@19.2.5): + lucide-react@1.11.0(react@19.2.5): dependencies: react: 19.2.5 @@ -8195,7 +7879,7 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - markdown-to-jsx@9.7.15(react@19.2.5): + markdown-to-jsx@9.7.16(react@19.2.5): optionalDependencies: react: 19.2.5 @@ -8209,15 +7893,8 @@ snapshots: merge-descriptors@1.0.3: {} - merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -8271,7 +7948,7 @@ snapshots: stacktrace-js: 2.0.2 stylis: 4.3.6 - nanoid@3.3.11: {} + nanoid@3.3.12: {} nanoid@5.1.9: {} @@ -8302,7 +7979,7 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-releases@2.0.38: {} + node-releases@2.0.36: {} nprogress@0.2.0: {} @@ -8336,35 +8013,32 @@ snapshots: orderedmap@2.1.1: {} - oxc-parser@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + oxc-parser@0.127.0: dependencies: - '@oxc-project/types': 0.121.0 + '@oxc-project/types': 0.127.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.121.0 - '@oxc-parser/binding-android-arm64': 0.121.0 - '@oxc-parser/binding-darwin-arm64': 0.121.0 - '@oxc-parser/binding-darwin-x64': 0.121.0 - '@oxc-parser/binding-freebsd-x64': 0.121.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.121.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.121.0 - '@oxc-parser/binding-linux-arm64-musl': 0.121.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.121.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.121.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.121.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.121.0 - '@oxc-parser/binding-linux-x64-gnu': 0.121.0 - '@oxc-parser/binding-linux-x64-musl': 0.121.0 - '@oxc-parser/binding-openharmony-arm64': 0.121.0 - '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 - '@oxc-parser/binding-win32-x64-msvc': 0.121.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@oxc-parser/binding-android-arm-eabi': 0.127.0 + '@oxc-parser/binding-android-arm64': 0.127.0 + '@oxc-parser/binding-darwin-arm64': 0.127.0 + '@oxc-parser/binding-darwin-x64': 0.127.0 + '@oxc-parser/binding-freebsd-x64': 0.127.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.127.0 + '@oxc-parser/binding-linux-arm64-musl': 0.127.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.127.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-musl': 0.127.0 + '@oxc-parser/binding-openharmony-arm64': 0.127.0 + '@oxc-parser/binding-wasm32-wasi': 0.127.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.127.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 + '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -8382,7 +8056,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -8398,7 +8072,7 @@ snapshots: parseurl@1.3.3: {} - partysocket@1.1.16(react@19.2.5): + partysocket@1.1.18(react@19.2.5): dependencies: event-target-polyfill: 0.0.4 optionalDependencies: @@ -8416,11 +8090,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.4: {} - pkg-types@2.3.0: + pkg-types@2.3.1: dependencies: confbox: 0.2.4 exsolve: 1.0.8 @@ -8438,9 +8110,9 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.10: + postcss@8.5.13: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8459,7 +8131,7 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 - prettier@3.8.3: {} + prettier@3.8.1: {} prop-types@15.8.1: dependencies: @@ -8586,13 +8258,19 @@ snapshots: punycode@1.3.2: {} + qrcode.react@4.2.0(react@19.2.5): + dependencies: + react: 19.2.5 + qs@6.14.2: dependencies: side-channel: 1.1.0 - querystring@0.2.0: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 - queue-microtask@1.2.3: {} + querystring@0.2.0: {} railroad-diagrams@1.0.0: {} @@ -8679,11 +8357,11 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-i18next@16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): + react-i18next@17.0.4(i18next@26.0.8(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 25.10.10(typescript@6.0.3) + i18next: 26.0.8(typescript@6.0.3) react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: @@ -8713,7 +8391,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: cookie: 1.1.1 react: 19.2.5 @@ -8790,12 +8468,12 @@ snapshots: remix-auth@4.2.0: {} - remix-i18next@7.4.2(i18next@25.10.10(typescript@6.0.3))(react-i18next@16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): + remix-i18next@7.5.0(i18next@26.0.8(typescript@6.0.3))(react-i18next@17.0.4(i18next@26.0.8(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): dependencies: - i18next: 25.10.10(typescript@6.0.3) + i18next: 26.0.8(typescript@6.0.3) react: 19.2.5 - react-i18next: 16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) - react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-i18next: 17.0.4(i18next@26.0.8(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) require-directory@2.1.1: {} @@ -8805,58 +8483,56 @@ snapshots: ret@0.1.15: {} - reusify@1.1.0: {} - - rolldown@1.0.0-rc.15: + rolldown@1.0.0-rc.17: dependencies: - '@oxc-project/types': 0.124.0 - '@rolldown/pluginutils': 1.0.0-rc.15 + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-x64': 1.0.0-rc.15 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 - rollup@4.60.2: + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -8865,10 +8541,6 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - runes2@1.1.4: {} sade@1.8.1: @@ -8950,7 +8622,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.1: + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -8974,7 +8646,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.1 + side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -9039,7 +8711,7 @@ snapshots: statuses@2.0.2: {} - std-env@4.1.0: {} + std-env@4.0.0: {} stream-browserify@3.0.0: dependencies: @@ -9101,7 +8773,7 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.1.1: {} + tinyexec@1.1.2: {} tinyglobby@0.2.16: dependencies: @@ -9143,10 +8815,6 @@ snapshots: - '@types/react' - '@types/react-dom' - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - toggle-selection@1.0.6: {} toidentifier@1.0.1: {} @@ -9191,7 +8859,7 @@ snapshots: uc.micro@2.1.0: {} - unbash@2.2.0: {} + unbash@3.0.0: {} undici-types@7.19.2: {} @@ -9199,9 +8867,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 @@ -9249,13 +8917,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@types/node' - jiti @@ -9270,13 +8938,13 @@ snapshots: - tsx - yaml - vite-node@6.0.0(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vite-node@6.0.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4): dependencies: cac: 7.0.0 es-module-lexer: 2.0.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -9291,18 +8959,18 @@ snapshots: - tsx - yaml - vite-plugin-babel@1.6.0(@babel/core@7.29.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-plugin-babel@1.6.0(@babel/core@7.29.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.0 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: - esbuild: 0.27.7 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 - rollup: 4.60.2 + postcss: 8.5.13 + rollup: 4.60.0 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 @@ -9310,58 +8978,58 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.8.4 - vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.10 - rolldown: 1.0.0-rc.15 + postcss: 8.5.13 + rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 - esbuild: 0.27.7 + esbuild: 0.27.4 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.8.4 - vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4): + vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5): dependencies: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - vitest@4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 4.1.0 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.1.1 + tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) - '@vitest/ui': 4.1.4(vitest@4.1.4) + '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/ui': 4.1.5(vitest@4.1.5) transitivePeerDependencies: - msw @@ -9371,6 +9039,11 @@ snapshots: walk-up-path@4.0.0: {} + web-haptics@0.0.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + web-push@3.6.7: dependencies: asn1.js: 5.4.1 @@ -9428,7 +9101,7 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.3: {} + yaml@2.8.4: {} yargs-parser@21.1.1: {} diff --git a/public/static-assets/badges/itz50.avif b/public/static-assets/badges/itz50.avif new file mode 100644 index 000000000..00a76a819 Binary files /dev/null and b/public/static-assets/badges/itz50.avif differ diff --git a/public/static-assets/badges/itz50.gif b/public/static-assets/badges/itz50.gif new file mode 100644 index 000000000..ca9d949be Binary files /dev/null and b/public/static-assets/badges/itz50.gif differ diff --git a/public/static-assets/badges/moneycat.avif b/public/static-assets/badges/moneycat.avif new file mode 100644 index 000000000..d078a411d Binary files /dev/null and b/public/static-assets/badges/moneycat.avif differ diff --git a/public/static-assets/badges/moneycat.gif b/public/static-assets/badges/moneycat.gif new file mode 100644 index 000000000..de2ae84d8 Binary files /dev/null and b/public/static-assets/badges/moneycat.gif differ diff --git a/public/static-assets/badges/sssge.avif b/public/static-assets/badges/sssge.avif new file mode 100644 index 000000000..f99071bfe Binary files /dev/null and b/public/static-assets/badges/sssge.avif differ diff --git a/public/static-assets/badges/sssge.gif b/public/static-assets/badges/sssge.gif new file mode 100644 index 000000000..5797f1956 Binary files /dev/null and b/public/static-assets/badges/sssge.gif differ diff --git a/public/static-assets/badges/wellstringcustom.avif b/public/static-assets/badges/wellstringcustom.avif new file mode 100644 index 000000000..c70eb96d0 Binary files /dev/null and b/public/static-assets/badges/wellstringcustom.avif differ diff --git a/public/static-assets/badges/wellstringcustom.gif b/public/static-assets/badges/wellstringcustom.gif new file mode 100644 index 000000000..2dcae265c Binary files /dev/null and b/public/static-assets/badges/wellstringcustom.gif differ diff --git a/public/static-assets/badges/wellstringregular.avif b/public/static-assets/badges/wellstringregular.avif new file mode 100644 index 000000000..b1199487d Binary files /dev/null and b/public/static-assets/badges/wellstringregular.avif differ diff --git a/public/static-assets/badges/wellstringregular.gif b/public/static-assets/badges/wellstringregular.gif new file mode 100644 index 000000000..c67c5ef4f Binary files /dev/null and b/public/static-assets/badges/wellstringregular.gif differ diff --git a/public/static-assets/badges/wings.avif b/public/static-assets/badges/wings.avif new file mode 100644 index 000000000..7f6536dbe Binary files /dev/null and b/public/static-assets/badges/wings.avif differ diff --git a/public/static-assets/badges/wings.gif b/public/static-assets/badges/wings.gif new file mode 100644 index 000000000..0deaa02a8 Binary files /dev/null and b/public/static-assets/badges/wings.gif differ diff --git a/public/static-assets/img/stage-banners/0.avif b/public/static-assets/img/stage-banners/0.avif new file mode 100644 index 000000000..398c0db47 Binary files /dev/null and b/public/static-assets/img/stage-banners/0.avif differ diff --git a/public/static-assets/img/stage-banners/1.avif b/public/static-assets/img/stage-banners/1.avif new file mode 100644 index 000000000..198003692 Binary files /dev/null and b/public/static-assets/img/stage-banners/1.avif differ diff --git a/public/static-assets/img/stage-banners/10.avif b/public/static-assets/img/stage-banners/10.avif new file mode 100644 index 000000000..dc701b5aa Binary files /dev/null and b/public/static-assets/img/stage-banners/10.avif differ diff --git a/public/static-assets/img/stage-banners/11.avif b/public/static-assets/img/stage-banners/11.avif new file mode 100644 index 000000000..710e9cb08 Binary files /dev/null and b/public/static-assets/img/stage-banners/11.avif differ diff --git a/public/static-assets/img/stage-banners/12.avif b/public/static-assets/img/stage-banners/12.avif new file mode 100644 index 000000000..9467ed662 Binary files /dev/null and b/public/static-assets/img/stage-banners/12.avif differ diff --git a/public/static-assets/img/stage-banners/13.avif b/public/static-assets/img/stage-banners/13.avif new file mode 100644 index 000000000..a45d9750d Binary files /dev/null and b/public/static-assets/img/stage-banners/13.avif differ diff --git a/public/static-assets/img/stage-banners/14.avif b/public/static-assets/img/stage-banners/14.avif new file mode 100644 index 000000000..c95c43728 Binary files /dev/null and b/public/static-assets/img/stage-banners/14.avif differ diff --git a/public/static-assets/img/stage-banners/15.avif b/public/static-assets/img/stage-banners/15.avif new file mode 100644 index 000000000..52e2092bf Binary files /dev/null and b/public/static-assets/img/stage-banners/15.avif differ diff --git a/public/static-assets/img/stage-banners/16.avif b/public/static-assets/img/stage-banners/16.avif new file mode 100644 index 000000000..b07ae3a21 Binary files /dev/null and b/public/static-assets/img/stage-banners/16.avif differ diff --git a/public/static-assets/img/stage-banners/17.avif b/public/static-assets/img/stage-banners/17.avif new file mode 100644 index 000000000..564285b01 Binary files /dev/null and b/public/static-assets/img/stage-banners/17.avif differ diff --git a/public/static-assets/img/stage-banners/18.avif b/public/static-assets/img/stage-banners/18.avif new file mode 100644 index 000000000..4684fb7cb Binary files /dev/null and b/public/static-assets/img/stage-banners/18.avif differ diff --git a/public/static-assets/img/stage-banners/19.avif b/public/static-assets/img/stage-banners/19.avif new file mode 100644 index 000000000..effadfec1 Binary files /dev/null and b/public/static-assets/img/stage-banners/19.avif differ diff --git a/public/static-assets/img/stage-banners/2.avif b/public/static-assets/img/stage-banners/2.avif new file mode 100644 index 000000000..3fbc7717f Binary files /dev/null and b/public/static-assets/img/stage-banners/2.avif differ diff --git a/public/static-assets/img/stage-banners/20.avif b/public/static-assets/img/stage-banners/20.avif new file mode 100644 index 000000000..8058bd1bc Binary files /dev/null and b/public/static-assets/img/stage-banners/20.avif differ diff --git a/public/static-assets/img/stage-banners/21.avif b/public/static-assets/img/stage-banners/21.avif new file mode 100644 index 000000000..323e01805 Binary files /dev/null and b/public/static-assets/img/stage-banners/21.avif differ diff --git a/public/static-assets/img/stage-banners/22.avif b/public/static-assets/img/stage-banners/22.avif new file mode 100644 index 000000000..f000cbc1e Binary files /dev/null and b/public/static-assets/img/stage-banners/22.avif differ diff --git a/public/static-assets/img/stage-banners/23.avif b/public/static-assets/img/stage-banners/23.avif new file mode 100644 index 000000000..7d443c251 Binary files /dev/null and b/public/static-assets/img/stage-banners/23.avif differ diff --git a/public/static-assets/img/stage-banners/24.avif b/public/static-assets/img/stage-banners/24.avif new file mode 100644 index 000000000..536cafb40 Binary files /dev/null and b/public/static-assets/img/stage-banners/24.avif differ diff --git a/public/static-assets/img/stage-banners/3.avif b/public/static-assets/img/stage-banners/3.avif new file mode 100644 index 000000000..554bb255f Binary files /dev/null and b/public/static-assets/img/stage-banners/3.avif differ diff --git a/public/static-assets/img/stage-banners/4.avif b/public/static-assets/img/stage-banners/4.avif new file mode 100644 index 000000000..a5e062311 Binary files /dev/null and b/public/static-assets/img/stage-banners/4.avif differ diff --git a/public/static-assets/img/stage-banners/5.avif b/public/static-assets/img/stage-banners/5.avif new file mode 100644 index 000000000..f279776bb Binary files /dev/null and b/public/static-assets/img/stage-banners/5.avif differ diff --git a/public/static-assets/img/stage-banners/6.avif b/public/static-assets/img/stage-banners/6.avif new file mode 100644 index 000000000..1ac4cdcb3 Binary files /dev/null and b/public/static-assets/img/stage-banners/6.avif differ diff --git a/public/static-assets/img/stage-banners/7.avif b/public/static-assets/img/stage-banners/7.avif new file mode 100644 index 000000000..70d40f9a7 Binary files /dev/null and b/public/static-assets/img/stage-banners/7.avif differ diff --git a/public/static-assets/img/stage-banners/8.avif b/public/static-assets/img/stage-banners/8.avif new file mode 100644 index 000000000..9db663afd Binary files /dev/null and b/public/static-assets/img/stage-banners/8.avif differ diff --git a/public/static-assets/img/stage-banners/9.avif b/public/static-assets/img/stage-banners/9.avif new file mode 100644 index 000000000..a27866c4c Binary files /dev/null and b/public/static-assets/img/stage-banners/9.avif differ diff --git a/scripts/backfill-tournament-result-divisions.ts b/scripts/backfill-tournament-result-divisions.ts index 902914613..13cdc7f21 100644 --- a/scripts/backfill-tournament-result-divisions.ts +++ b/scripts/backfill-tournament-result-divisions.ts @@ -10,7 +10,7 @@ import { import * as Standings from "../app/features/tournament/core/Standings"; import { tournamentSummary } from "../app/features/tournament-bracket/core/summarizer.server"; import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server"; -import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; +import { allMatchResultsByTournamentId } from "../app/features/tournament-match/queries/allMatchResultsByTournamentId.server"; import invariant from "../app/utils/invariant"; import { logger } from "../app/utils/logger"; diff --git a/scripts/calc-seeding-skills.ts b/scripts/calc-seeding-skills.ts index 1f607883b..2a34b2ef5 100644 --- a/scripts/calc-seeding-skills.ts +++ b/scripts/calc-seeding-skills.ts @@ -4,7 +4,7 @@ import { db } from "../app/db/sql"; import type { Tables } from "../app/db/tables"; import { calculateIndividualPlayerSkills } from "../app/features/tournament-bracket/core/summarizer.server"; import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server"; -import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; +import { allMatchResultsByTournamentId } from "../app/features/tournament-match/queries/allMatchResultsByTournamentId.server"; import invariant from "../app/utils/invariant"; import { logger } from "../app/utils/logger"; diff --git a/scripts/calc-tournament-summary-result-arrays.ts b/scripts/calc-tournament-summary-result-arrays.ts index 767a1f2b1..d68e2c0ec 100644 --- a/scripts/calc-tournament-summary-result-arrays.ts +++ b/scripts/calc-tournament-summary-result-arrays.ts @@ -6,7 +6,7 @@ import { type TournamentSummary, } from "../app/features/tournament-bracket/core/summarizer.server"; import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server"; -import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; +import { allMatchResultsByTournamentId } from "../app/features/tournament-match/queries/allMatchResultsByTournamentId.server"; import invariant from "../app/utils/invariant"; import { logger } from "../app/utils/logger"; diff --git a/scripts/create-analyzer-json.ts b/scripts/create-analyzer-json.ts index 03b030877..0d3ee4c16 100644 --- a/scripts/create-analyzer-json.ts +++ b/scripts/create-analyzer-json.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - // To run this script you need from https://github.com/Leanny/leanny.github.io // 1) WeaponInfoMain.json inside dicts // 2) WeaponInfoSub.json inside dicts @@ -18,10 +16,13 @@ import type { SubWeaponParams, WeaponKit, } from "~/features/build-analyzer/analyzer-types"; +import type { + MainWeaponId, + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import { - type SpecialWeaponId, SQUID_BEAKON_ID, - type SubWeaponId, subWeaponIds, weaponIdToBaseWeaponId, } from "~/modules/in-game-lists/weapon-ids"; @@ -109,7 +110,8 @@ async function main() { if (specialWeaponShouldBeSkipped(specialWeapon)) continue; const rawParams = loadWeaponParamsObject(specialWeapon); - const params = parametersToSpecialWeaponResult(rawParams); + const params: Record = + parametersToSpecialWeaponResult(rawParams); // Super Chumps has two distinct splash damage values (near/far) // that should be labeled separately in the analyzer @@ -179,7 +181,7 @@ function splitIntoBaseStatsAndKits( > = {}; for (const [idStr, params] of Object.entries(allParams)) { - const id = Number(idStr); + const id = Number(idStr) as MainWeaponId; const baseId = weaponIdToBaseWeaponId(id); if (!weaponGroups[baseId]) weaponGroups[baseId] = []; weaponGroups[baseId].push({ id, params }); @@ -497,11 +499,11 @@ function parametersToMainWeaponResult( const resolveMin = ( valueOne: number | null | undefined, valueTwo: number | null | undefined, - ) => { + ): number | undefined => { if (typeof valueOne !== "number" && typeof valueTwo !== "number") return undefined; - if (typeof valueOne !== "number") return valueTwo; + if (typeof valueOne !== "number") return valueTwo as number; if (typeof valueTwo !== "number") return valueOne; return Math.min(valueOne, valueTwo); @@ -510,11 +512,11 @@ function parametersToMainWeaponResult( const resolveMax = ( valueOne: number | null | undefined, valueTwo: number | null | undefined, - ) => { + ): number | undefined => { if (typeof valueOne !== "number" && typeof valueTwo !== "number") return undefined; - if (typeof valueOne !== "number") return valueTwo; + if (typeof valueOne !== "number") return valueTwo as number; if (typeof valueTwo !== "number") return valueOne; return Math.max(valueOne, valueTwo); diff --git a/scripts/create-gear-json.ts b/scripts/create-gear-json.ts index bac47f907..96f7780dd 100644 --- a/scripts/create-gear-json.ts +++ b/scripts/create-gear-json.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -57,7 +55,13 @@ async function main() { internalName, brand: gear.Brand, translations: langDicts.map(([langCode, translations]) => { - const name = translations[categoryKey]?.[internalName]; + const category = ( + translations as unknown as Record< + string, + Record | undefined + > + )[categoryKey]; + const name = category?.[internalName]; invariant(name, `Missing translation for ${internalName}`); return { diff --git a/scripts/create-misc-json.ts b/scripts/create-misc-json.ts index 2e88473d9..f181852d9 100644 --- a/scripts/create-misc-json.ts +++ b/scripts/create-misc-json.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,6 +10,9 @@ import { translationJsonFolderName, } from "./utils"; +type LangDicts = Awaited>; +type LangDict = LangDicts[number][1]; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -176,7 +177,9 @@ async function main() { invariant(internalName, `Missing internal name for ${ability}`); const translation = decodeURIComponent( - langDict["CommonMsg/Gear/GearPowerName"][internalName], + (langDict["CommonMsg/Gear/GearPowerName"] as Record)[ + internalName + ], ); translationsMap[`ABILITY_${ability}`] = translation; @@ -216,7 +219,7 @@ async function main() { generateBadgeData(langDicts); } -function generateBadgeData(langDicts) { +function generateBadgeData(langDicts: LangDicts) { const badgeFiles = fs .readdirSync(BADGE_DIR) .filter((f) => f.endsWith(".png")); @@ -271,9 +274,12 @@ function generateBadgeData(langDicts) { fs.writeFileSync(path.join(OUTPUT_DIR, "game-badge-ids.ts"), tsContent); } -function buildBadgeTranslations(badgeIds, langDict) { - const badgeMsg = langDict["CommonMsg/Badge/BadgeMsg"]; - const translationsMap = {}; +function buildBadgeTranslations(badgeIds: string[], langDict: LangDict) { + const badgeMsg = langDict["CommonMsg/Badge/BadgeMsg"] as Record< + string, + string + >; + const translationsMap: Record = {}; for (const id of badgeIds) { if (badgeMsg[id] && !badgeMsg[id].includes("[group=")) { @@ -306,7 +312,9 @@ function buildBadgeTranslations(badgeIds, langDict) { continue; } - const lookupValue = langDict[rule.lookupDict]?.[variantName]; + const lookupValue = ( + langDict as unknown as Record | undefined> + )[rule.lookupDict]?.[variantName]; if (!lookupValue) { translationsMap[id] = id; continue; @@ -321,7 +329,10 @@ function buildBadgeTranslations(badgeIds, langDict) { return translationsMap; } -function writeBadgeJson(folder, translationsMap) { +function writeBadgeJson( + folder: string, + translationsMap: Record, +) { fs.writeFileSync( path.join(__dirname, "..", "locales", folder, "game-badges.json"), `${JSON.stringify(translationsMap, null, 2)}\n`, diff --git a/scripts/create-object-dmg-json.ts b/scripts/create-object-dmg-json.ts index 7dcc3bc7b..eaec40c38 100644 --- a/scripts/create-object-dmg-json.ts +++ b/scripts/create-object-dmg-json.ts @@ -1,9 +1,12 @@ -// @ts-nocheck - import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants"; +import type { + MainWeaponId, + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import { mainWeaponIds, specialWeaponIds, @@ -23,6 +26,21 @@ const __dirname = path.dirname(__filename); const OUTPUT_DIR_PATH = path.join(__dirname, "output"); +type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number]; + +type ResultEntry = { + mainWeaponIds: MainWeaponId[]; + subWeaponIds: SubWeaponId[]; + specialWeaponIds: SpecialWeaponId[]; + rates: { target: string; rate: number }[]; +}; + +type DamageRateCell = { + ColumnKey: string; + RowKey: string; + DamageRate?: number; +}; + const weaponParamsToWeaponIds = ( params: typeof weapons | typeof subWeapons | typeof specialWeapons, key: string, @@ -39,39 +57,48 @@ const weaponParamsToWeaponIds = ( .map((weapon) => weapon.Id); }; -const result = {}; -for (const cell of Object.values(params.CellList)) { - if (!DAMAGE_RECEIVERS.includes(cell.ColumnKey)) continue; +const isDamageReceiver = (key: string): key is DamageReceiver => + (DAMAGE_RECEIVERS as readonly string[]).includes(key); + +const result: Record = {}; +for (const cell of Object.values(params.CellList) as DamageRateCell[]) { + if (!isDamageReceiver(cell.ColumnKey)) continue; if (!cell.DamageRate) continue; if (!result[cell.RowKey]) { result[cell.RowKey] = { mainWeaponIds: weaponParamsToWeaponIds(weapons, cell.RowKey).filter( - (id) => mainWeaponIds.includes(id), + (id): id is MainWeaponId => + (mainWeaponIds as readonly number[]).includes(id), ), subWeaponIds: weaponParamsToWeaponIds(subWeapons, cell.RowKey).filter( - (id) => subWeaponIds.includes(id), + (id): id is SubWeaponId => + (subWeaponIds as readonly number[]).includes(id), ), specialWeaponIds: weaponParamsToWeaponIds( specialWeapons, cell.RowKey, - ).filter((id) => specialWeaponIds.includes(id)), + ).filter((id): id is SpecialWeaponId => + (specialWeaponIds as readonly number[]).includes(id), + ), rates: [], }; } + const entry = result[cell.RowKey]!; + // if it applies to no PvP weapons, we don't care about it if ( - result[cell.RowKey].mainWeaponIds.length === 0 && - result[cell.RowKey].subWeaponIds.length === 0 && - result[cell.RowKey].specialWeaponIds.length === 0 && + entry.mainWeaponIds.length === 0 && + entry.subWeaponIds.length === 0 && + entry.specialWeaponIds.length === 0 && cell.RowKey !== "ObjectEffect_Up" ) { result[cell.RowKey] = undefined; continue; } - result[cell.RowKey].rates.push({ + entry.rates.push({ target: cell.ColumnKey, rate: cell.DamageRate, }); @@ -81,7 +108,7 @@ for (const cell of Object.values(params.CellList)) { cell.ColumnKey.includes("BulletUmbrellaCanopyNormal") || cell.ColumnKey.includes("BulletUmbrellaCanopyWide") ) { - result[cell.RowKey].rates.push({ + entry.rates.push({ target: `${cell.ColumnKey}_Launched`, rate: cell.DamageRate, }); @@ -89,12 +116,12 @@ for (const cell of Object.values(params.CellList)) { // if it has special damage rates for Splat Brella, add the same value for Recycled Brella if (cell.ColumnKey === "BulletUmbrellaCanopyNormal") { - result[cell.RowKey].rates.push({ + entry.rates.push({ target: "BulletShelterCanopyFocus", rate: cell.DamageRate, }); - result[cell.RowKey].rates.push({ + entry.rates.push({ target: "BulletShelterCanopyFocus_Launched", rate: cell.DamageRate, }); diff --git a/scripts/nuke-reported-weapons.ts b/scripts/nuke-reported-weapons.ts index 080ce6d09..7d43b356b 100644 --- a/scripts/nuke-reported-weapons.ts +++ b/scripts/nuke-reported-weapons.ts @@ -22,7 +22,7 @@ async function main() { .where("User.discordId", "=", discordId) .executeTakeFirstOrThrow(); - const groupMatchMaps = await db + const playedMaps = await db .selectFrom("GroupMember") .innerJoin("Group", "Group.id", "GroupMember.groupId") .innerJoin("GroupMatch", (join) => @@ -34,7 +34,7 @@ async function main() { ), ) .innerJoin("GroupMatchMap", "GroupMatchMap.matchId", "GroupMatch.id") - .select("GroupMatchMap.id") + .select(["GroupMatchMap.matchId", "GroupMatchMap.index"]) .where( "GroupMatch.createdAt", ">", @@ -49,16 +49,28 @@ async function main() { .where("GroupMatchMap.winnerGroupId", "is not", null) .execute(); - const groupMatchMapIds = groupMatchMaps.map((gmm) => gmm.id); + if (playedMaps.length === 0) { + logger.info(`No reported weapons to delete for user ${discordId}`); + return; + } await db .deleteFrom("ReportedWeapon") .where("userId", "=", user.id) - .where("ReportedWeapon.groupMatchMapId", "in", groupMatchMapIds) + .where((eb) => + eb.or( + playedMaps.map((m) => + eb.and([ + eb("ReportedWeapon.groupMatchId", "=", m.matchId), + eb("ReportedWeapon.mapIndex", "=", m.index), + ]), + ), + ), + ) .execute(); logger.info( - `Deleted ${groupMatchMapIds.length} reported weapons for user ${discordId}`, + `Deleted reported weapons across ${playedMaps.length} maps for user ${discordId}`, ); } diff --git a/scripts/replace-weapon-names.ts b/scripts/replace-weapon-names.ts index 9acecec54..d141a5a09 100644 --- a/scripts/replace-weapon-names.ts +++ b/scripts/replace-weapon-names.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -63,8 +61,8 @@ async function main() { continue; } - const weapon: any = weapons.find( - (weapon: any) => + const weapon = (weapons as Array<{ __RowId: string; Id: number }>).find( + (weapon) => file.includes(`${weapon.__RowId}.`) || file.includes(`${weapon.__RowId}_`), ); diff --git a/scripts/season-initial-powers.ts b/scripts/season-initial-powers.ts index e1851a4f7..41a1bf781 100644 --- a/scripts/season-initial-powers.ts +++ b/scripts/season-initial-powers.ts @@ -3,8 +3,8 @@ import { ordinal } from "openskill"; import { db, sql } from "~/db/sql"; import type { Tables } from "~/db/tables"; import { TIERS, type TierName } from "~/features/mmr/mmr-constants"; +import * as SkillRepository from "~/features/mmr/SkillRepository.server"; import { freshUserSkills } from "~/features/mmr/tiered.server"; -import { addInitialSkill } from "~/features/sendouq/queries/addInitialSkill.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; @@ -174,18 +174,12 @@ const newSkills = allSkills.map((s) => { }; }); -const allGroupsInactiveStm = sql.prepare(/* sql */ ` - update - "Group" - set - "status" = 'INACTIVE' -`); -sql.transaction(() => { +await db.transaction().execute(async (trx) => { for (const skill of newSkills) { - addInitialSkill(skill); + await SkillRepository.addInitialSkill(skill, trx); } - allGroupsInactiveStm.run(); -})(); + await trx.updateTable("Group").set({ status: "INACTIVE" }).execute(); +}); logger.info( `Done adding new skills for season ${nth} (${newSkills.length} added)`, diff --git a/scripts/sync-weapon-params.ts b/scripts/sync-weapon-params.ts index 1d884991e..52f3ee88b 100644 --- a/scripts/sync-weapon-params.ts +++ b/scripts/sync-weapon-params.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..4719c9c7b --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "../types/**/*.d.ts"], + "exclude": [] +} diff --git a/scripts/utils.ts b/scripts/utils.ts index 6ef82e4f4..f4689d89c 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/tsconfig.json b/tsconfig.json index 95da52d0c..cf043241b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "**/*.tsx", ".react-router/types/**/*" ], + "exclude": ["scripts/**/*"], "compilerOptions": { "types": ["@react-router/node", "vite/client"], "rootDirs": [".", "./.react-router/types"],