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/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/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/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx index 8c41c2e68..5eb90a9ec 100644 --- a/app/components/layout/ChatSidebar.tsx +++ b/app/components/layout/ChatSidebar.tsx @@ -2,8 +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"; @@ -165,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); @@ -180,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, 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..c5d7a189a --- /dev/null +++ b/app/components/match-page/MatchTimeline.tsx @@ -0,0 +1,632 @@ +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 { 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 = 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 d3c333b85..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; } @@ -999,6 +1017,7 @@ export interface UserPreferences { 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; @@ -1059,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. */ @@ -1306,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]; @@ -1350,6 +1378,7 @@ export interface DB { Group: Group; GroupLike: GroupLike; GroupMatch: GroupMatch; + GroupMatchContinueVote: GroupMatchContinueVote; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; PrivateUserNote: PrivateUserNote; @@ -1362,6 +1391,7 @@ export interface DB { PlusTier: PlusTier; PlusVote: PlusVote; PlusVotingResult: PlusVotingResult; + RoomLink: RoomLink; ReportedWeapon: ReportedWeapon; Skill: Skill; SkillTeamUser: SkillTeamUser; 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/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index 881c3dd40..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(); } 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/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/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/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/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/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 e314648af..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/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 61aa8e551..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({ diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 9bf1a5a13..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,204 +563,12 @@ 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, @@ -788,7 +583,15 @@ export async function cancelMatch({ if (isAdminReport) { 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(match.groupAlpha.id, trx); await SQGroupRepository.setAsInactive(match.groupBravo.id, trx); await lockMatchWithoutSkillChange(match.id, trx); @@ -802,9 +605,7 @@ export async function cancelMatch({ )?.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, @@ -824,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); @@ -840,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>>, ) { @@ -854,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 615efdfbf..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 { @@ -13,20 +12,22 @@ import { 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) => { @@ -40,162 +41,69 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { schema: matchSchema, }); + 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", + ); + try { switch (data._action) { case "REPORT_SCORE": { - const unmappedMatch = notFoundIfFalsy( - await SQMatchRepository.findById(matchId), - ); - const match = SendouQ.mapMatch(unmappedMatch, user); + const isStaffReport = !isParticipant && isStaff; - 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, - })), - ); + const result = await SQMatchRepository.reportMapWinner({ + matchId, + winnerId: data.winnerId, + reportedByUserId: user.id, + reportedCount: data.reportedCount, + isStaffReport, + }); + + if (result.status === "ALREADY_LOCKED" || result.status === "STALE") { return null; } - errorToastIfFalsy( - !data.adminReport || user.roles.includes("STAFF"), - "Only mods can report scores as admin", - ); + if (result.status === "INVALID_WINNER") { + return errorToast("Invalid winner id"); + } - 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", - ); - - const matchIsBeingCanceled = data.winners.length === 0; - - if (data.adminReport && !matchIsBeingCanceled) { - await SQMatchRepository.adminReport({ - matchId, - reportedByUserId: user.id, - winners: data.winners, - }); + 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(); + await refreshSendouQInstance(); - if (match.chatCode) { + if (match.chatCode) { + if (result.status === "MATCH_FINALIZED") { ChatSystemMessage.send({ room: match.chatCode, type: "SCORE_CONFIRMED", context: { name: user.username }, }); - } - - break; - } - - if (matchIsBeingCanceled) { - const result = await SQMatchRepository.cancelMatch({ - matchId, - reportedByUserId: user.id, - isAdminReport: Boolean(data.adminReport), - }); - - if (result.shouldRefreshCaches) { - try { - refreshUserSkills(Seasons.currentOrPrevious()!.nth); - } catch (error) { - logger.warn("Error refreshing user skills", error); - } - 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"; - + } else { 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 }, - }); } break; @@ -204,9 +112,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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 @@ -218,15 +123,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { "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"); - if (member.id === user.id) { - errorToastIfFalsy( - member.role === "OWNER", - "You are not the owner of the group", - ); - } } await SQGroupRepository.createGroupFromPrevious({ @@ -235,37 +145,108 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { id: m.id, role: m.role, })), + status: "ACTIVE", }); await refreshSendouQInstance(); - throw redirect(SENDOUQ_PREPARING_PAGE); + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + revalidateOnly: true, + }); + } + + break; } - case "REPORT_WEAPONS": { - const match = notFoundIfFalsy( - await SQMatchRepository.findById(matchId), + case "CAST_CONTINUE_VOTE": { + const viewerSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: user.id, + }); + errorToastIfFalsy(viewerSide, "Not a participant"); + + const viewerGroup = + viewerSide === "ALPHA" ? match.groupAlpha : match.groupBravo; + errorToastIfFalsy( + viewerGroup.matchmade, + "This group uses the trusted rematch flow", ); - errorToastIfFalsy(match.reportedAt, "Match has not been reported yet"); - const oldReportedWeapons = - (await ReportedWeaponRepository.findByMatchId(matchId)) ?? []; + const votingResult = await db.transaction().execute(async (trx) => { + const existingVotes = + await GroupMatchContinueVoteRepository.findForGroups( + [viewerGroup.id], + trx, + ); - const mergedWeapons = mergeReportedWeapons({ - oldWeapons: oldReportedWeapons, - newWeapons: data.weapons as (ReportedWeapon & { - mapIndex: number; - groupMatchMapId: number; - })[], + 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, + ), + ); }); - await ReportedWeaponRepository.replaceByMatchId( + 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, - mergedWeapons.map((w) => ({ - groupMatchMapId: w.groupMatchMapId, - userId: w.userId, - weaponSplId: w.weaponSplId, - })), - ); + userId: user.id, + mapIndex: data.mapIndex, + }); break; } @@ -279,12 +260,166 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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"); + + const result = await SQMatchRepository.cancelMatch({ + matchId, + reportedByUserId: user.id, + isAdminReport: true, + }); + + if (result.shouldRefreshCaches) { + try { + refreshUserSkills(Seasons.currentOrPrevious()!.nth); + } catch (error) { + logger.warn("Error refreshing user skills", error); + } + refreshStreamsCache(); + } + + await refreshSendouQInstance(); + + if (match.chatCode) { + ChatSystemMessage.send({ + room: match.chatCode, + revalidateOnly: true, + }); + } + + break; + } + case "REFUSE_CANCEL": { + const result = await SQMatchRepository.refuseCancelMatch({ + matchId, + refusedByUserId: 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 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); } } - - return null; } 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 @@ -295,4 +430,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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 777a9503d..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"; @@ -53,10 +54,20 @@ export const action = async ({ request }: ActionFunctionArgs) => { "Not a friend", ); - await SQGroupRepository.addMember(ownGroup.id, { - userId: data.id, - role: "MANAGER", - }); + const { chatCodeToRevalidate } = await SQGroupRepository.addMember( + ownGroup.id, + { + userId: data.id, + role: "MANAGER", + }, + ); + + if (chatCodeToRevalidate) { + ChatSystemMessage.send({ + room: chatCodeToRevalidate, + revalidateOnly: true, + }); + } await refreshSendouQInstance(); diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 2da4fc4a3..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"; @@ -31,11 +32,18 @@ export const action: ActionFunction = async ({ request }) => { case "JOIN_QUEUE": { await validateCanJoinQ(user); - await SQGroupRepository.createGroup({ + const { chatCodeToRevalidate } = await SQGroupRepository.createGroup({ status: data.direct === "true" ? "ACTIVE" : "PREPARING", userId: user.id, }); + if (chatCodeToRevalidate) { + ChatSystemMessage.send({ + room: chatCodeToRevalidate, + revalidateOnly: true, + }); + } + await refreshSendouQInstance(); return redirect( @@ -58,10 +66,20 @@ export const action: ActionFunction = async ({ request }) => { "Invite code doesn't match any active team", ); - await SQGroupRepository.addMember(groupInvitedTo.id, { - userId: user.id, - role: "MANAGER", - }); + const { chatCodeToRevalidate } = await SQGroupRepository.addMember( + groupInvitedTo.id, + { + userId: user.id, + role: "MANAGER", + }, + ); + + if (chatCodeToRevalidate) { + ChatSystemMessage.send({ + room: chatCodeToRevalidate, + revalidateOnly: true, + }); + } await refreshSendouQInstance(); diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index a2a7e9942..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 23c479e7a..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(); }; 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/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 536cdc547..40f137eec 100644 --- a/app/features/sendouq/routes/q.tsx +++ b/app/features/sendouq/routes/q.tsx @@ -190,12 +190,11 @@ function Clocks() { const isHydrated = useHydrated(); const { t } = useTranslation(["q"]); const { formatDate, formatTime } = useTimeFormat(); - useAutoRerender(); + const now = useAutoRerender(); return (
{countries.map((country) => { - const now = new Date(); return (
diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 6c1ed418e..b1b7fd6ed 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -53,12 +53,25 @@ 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, 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 ca2d5ea33..a2574a1e6 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -35,6 +35,7 @@ import { disallowScrimPickupsFromUntrustedSchema, spoilerFreeModeSchema, updateNoScreenSchema, + updateNoSplatnetSchema, } from "../settings-schemas"; import styles from "./settings.module.css"; import "./settings.global.css"; @@ -140,6 +141,16 @@ export default function SettingsPage() { > {({ FormField }) => } + + {({ FormField }) => } + - Scr + KOs ) : null} Seed @@ -361,7 +362,7 @@ function StandingsTable({ ) : null} {bracket.type === "round_robin" ? ( - {stats.points} + {stats.koCount ?? 0} ) : null} {team?.seed} diff --git a/app/features/tournament-bracket/components/CastInfo.tsx b/app/features/tournament-bracket/components/CastInfo.tsx deleted file mode 100644 index 01c1fe618..000000000 --- a/app/features/tournament-bracket/components/CastInfo.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { Lock, LockOpen } from "lucide-react"; -import type { JSX } from "react"; -import { useFetcher } from "react-router"; -import { InfoPopover } from "~/components/InfoPopover"; -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 styles from "../tournament-bracket.module.css"; - -const 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."; -const setAsCastedInfo = - "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view."; - -export function CastInfo({ - matchIsOngoing, - matchId, - matchIsOver, - matchStatus, -}: { - matchIsOngoing: boolean; - matchId: number; - matchIsOver: boolean; - matchStatus: number; -}) { - const user = useUser(); - const tournament = useTournament(); - - const castedMatchesInfo = tournament.ctx.castedMatchesInfo; - const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? []; - const currentlyCastedOn = castedMatchesInfo?.castedMatches.find( - (cm) => cm.matchId === matchId, - )?.twitchAccount; - const isLocked = castedMatchesInfo?.lockedMatches?.some( - (lm) => lm.matchId === matchId, - ); - - const hasPerms = tournament.isOrganizerOrStreamer(user); - - if (castTwitchAccounts.length === 0 || !hasPerms || matchIsOver) return null; - - // match can only be locked when status is Locked or Waiting (team(s) busy with previous match) - if ( - (matchStatus === TournamentMatchStatus.Locked || - matchStatus === TournamentMatchStatus.Waiting) && - !isLocked - ) { - return ( - } - infoText={lockingInfo} - > - {castTwitchAccounts.length > 1 ? ( - - ) : ( - - )} - - ); - } - - // 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 88e023ae0..000000000 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ /dev/null @@ -1,899 +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 { useTimeFormat } from "~/hooks/useTimeFormat"; -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 { formatDateTime } = useTimeFormat(); - 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 - ? formatDateTime(databaseTimestampToDate(result.createdAt), { - day: "numeric", - month: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }) - : "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 { formatDate } = useTimeFormat(); - 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{" "} - {formatDate( - resolveLeagueRoundStartDate(tournament, data.match.roundId)!, - { - day: "numeric", - month: "numeric", - year: "numeric", - }, - )}{" "} - 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/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/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 88a1c7a33..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 @@ -332,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 @@ -405,6 +407,8 @@ export class Match { opponentTwo: string; opponentOnePointsTotal: number | null; opponentTwoPointsTotal: number | null; + opponentOneKosTotal: number | null; + opponentTwoKosTotal: number | null; startedAt: number | null; }, ): MatchType { @@ -418,6 +422,7 @@ export class Match { : { ...JSON.parse(rawMatch.opponentOne), totalPoints: rawMatch.opponentOnePointsTotal ?? undefined, + totalKos: rawMatch.opponentOneKosTotal ?? undefined, }, opponent2: rawMatch.opponentTwo === "null" @@ -425,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/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/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-bracket/TournamentMatchRepository.server.test.ts b/app/features/tournament-match/TournamentMatchRepository.server.test.ts similarity index 100% rename from app/features/tournament-bracket/TournamentMatchRepository.server.test.ts rename to app/features/tournament-match/TournamentMatchRepository.server.test.ts diff --git a/app/features/tournament-bracket/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts similarity index 99% rename from app/features/tournament-bracket/TournamentMatchRepository.server.ts rename to app/features/tournament-match/TournamentMatchRepository.server.ts index 4941582e2..24f230816 100644 --- a/app/features/tournament-bracket/TournamentMatchRepository.server.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -91,6 +91,7 @@ export function findResultById(id: number) { "TournamentMatchGameResult.id", "TournamentMatchGameResult.opponentOnePoints", "TournamentMatchGameResult.opponentTwoPoints", + "TournamentMatchGameResult.winnerTeamId", ]) .where("TournamentMatchGameResult.id", "=", id) .executeTakeFirst(); 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 80% 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 8ace38f65..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,28 +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({ @@ -52,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"; @@ -199,19 +204,42 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const visibleChatCode = 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: 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/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 7d32df730..f713b3084 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -947,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/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 a4898c224..8be1f9e1c 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -1,5 +1,5 @@ import type { Tables } from "~/db/tables"; -import type { FindByTournamentTeamIdItem } from "~/features/tournament-bracket/TournamentMatchRepository.server"; +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"; 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 241a0c40c..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,7 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; -import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.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 { diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index b17fcb599..2066edc73 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -488,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} @@ -515,13 +508,11 @@ function RegistrationProgress({ } function CheckIn({ - status, canCheckIn, startDate, endDate, checkedIn, }: { - status: "OVER" | "OPEN" | "UPCOMING"; canCheckIn: boolean; startDate: Date; endDate: Date; @@ -532,7 +523,9 @@ function CheckIn({ const fetcher = useFetcher(); const { formatTime } = useTimeFormat(); - useAutoRerender(); + const now = useAutoRerender(); + const status: "OVER" | "OPEN" | "UPCOMING" = + now > endDate ? "OVER" : now >= startDate ? "OPEN" : "UPCOMING"; const checkInStartsString = isHydrated ? formatTime(startDate, { 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 06c7a1001..913134ce7 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1277,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/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/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/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/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/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/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/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/db-test.sqlite3 b/db-test.sqlite3 index bacb3ed0e..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 3e7a8a9f5..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 d6d212184..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 a5591dacb..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 1ab0177d3..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 758a7b61c..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 d1df2e659..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 e0f143e6a..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 288ef1627..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 8755519af..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 d3c6081d5..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..383055ce7 --- /dev/null +++ b/e2e/sendouq-match.spec.ts @@ -0,0 +1,274 @@ +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(); + }); +} + +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 cec5be098..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,47 +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"); - // TEAM_MAP_PREFS seeds exactly two groups (admin's + NZAP's); wait for - // both to render before clicking so we don't race the loader - await expect(groupCards).toHaveCount(2); - 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(); - // fetcher form submit is async; wait for the button to flip to "Undo" - // so the LIKE is persisted before we switch users - await expect( - groupCard.getByRole("button", { name: "Undo" }), - ).toBeVisible(); - } - - 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/common.json b/locales/da/common.json index f1d2054e8..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": "", @@ -317,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 4c44c9ab2..5cfe6d1c1 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/da/q.json b/locales/da/q.json index 53f9f1924..d751dcf8c 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 faddf2356..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", @@ -188,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/common.json b/locales/de/common.json index 375783ef9..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": "", @@ -317,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 6d22de1c0..bec561d9e 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/de/q.json b/locales/de/q.json index 287f73782..1728323c7 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 7f8313ab4..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.", @@ -188,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/common.json b/locales/en/common.json index ba8ae7f93..c9d8b26df 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", @@ -317,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", diff --git a/locales/en/forms.json b/locales/en/forms.json index a10a0d002..e1186c98a 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -9,6 +9,7 @@ "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)", diff --git a/locales/en/q.json b/locales/en/q.json index 3ebb2012b..4d3b5834e 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -125,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.", @@ -143,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.", @@ -166,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 443bf32f8..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.", @@ -188,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/common.json b/locales/es-ES/common.json index 124a7df17..8a06e85e3 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -143,6 +143,7 @@ "actions.disable": "Desactivar", "actions.accept": "Aceptar", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "Confirmar", "actions.next": "Siguiente", "actions.previous": "Anterior", @@ -319,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": "", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 221800b60..5738fd04e 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -9,6 +9,7 @@ "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)", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index b58189ad3..6409331fa 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -125,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.", @@ -143,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.", @@ -166,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 a429ec1cb..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.", @@ -190,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/common.json b/locales/es-US/common.json index dcf059b89..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": "", @@ -319,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": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index eb061520c..457e0aa0c 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 3fbf4ca49..9890f0400 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -125,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.", @@ -143,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.", @@ -166,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 0e07fcbc3..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.", @@ -190,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/common.json b/locales/fr-CA/common.json index ffda4dacc..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": "", @@ -319,6 +320,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 6ac11be50..e7cb063b4 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 29831249f..96ab0754c 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 5604107e0..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.", @@ -190,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/common.json b/locales/fr-EU/common.json index 99b001139..ab70d87f3 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -143,6 +143,7 @@ "actions.disable": "Désactiver", "actions.accept": "Accepter", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -319,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": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 432386794..6796beb32 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 09046831a..206727bc9 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -125,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.", @@ -143,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.", @@ -166,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 3ca817a34..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.", @@ -190,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/common.json b/locales/he/common.json index fb3a3de00..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": "", @@ -318,6 +319,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index f87308a3f..65896bad0 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -9,6 +9,7 @@ "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו", "bottomTexts.tag": "", diff --git a/locales/he/q.json b/locales/he/q.json index d22ea66a5..792e4abdf 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 8f4157684..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": "אין צוות שתואם את קוד ההזמנה.", @@ -190,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/common.json b/locales/it/common.json index d4b4816ef..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": "", @@ -319,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": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 1c90b3e1e..f77d1f390 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/it/q.json b/locales/it/q.json index a9bfbe41a..03edcaeb8 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -125,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", @@ -143,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.", @@ -166,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 2f639211b..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.", @@ -190,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/common.json b/locales/ja/common.json index 3d8b17084..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": "", @@ -313,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": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 0c7c5e70f..78e493d6f 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -9,6 +9,7 @@ "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。", "bottomTexts.tag": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index 6fc276e11..53040843e 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -125,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": "間違いがあった場合相手側のチームと話し、情報を合わせてください。両チームが同じスコアを報告するまでスコアは再報告できます。", @@ -143,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": "報告したスコアが相手と違います。今一度ご確認し、それでも違う場合は、ページの上の異議申立ての仕方を見てください。", @@ -166,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 69b662a50..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": "この招待コードにあうチームがみつかりません。", @@ -184,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/common.json b/locales/ko/common.json index 329a60b7e..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": "", @@ -313,6 +314,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 5a0bb3ff8..3b3419e77 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -9,6 +9,7 @@ "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "", "bottomTexts.tag": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 287f73782..1728323c7 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 799620c5d..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": "", @@ -184,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/common.json b/locales/nl/common.json index de14553d9..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": "", @@ -317,6 +318,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 21dfdca79..095174289 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -9,6 +9,7 @@ "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "", "bottomTexts.tag": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 287f73782..1728323c7 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 5ccf6d645..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": "", @@ -188,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/common.json b/locales/pl/common.json index 819fcfef5..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": "", @@ -320,6 +321,7 @@ "chat.systemMsg.scoreConfirmed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", + "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", "chat.newMessages": "", "chat.sidebar.title": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 4d1a3990a..d6d7c5ce5 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 287f73782..1728323c7 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -125,6 +125,7 @@ "looking.range.or": "", "looking.range.or.explanation": "", "match.header": "", + "match.mapVoters.header": "", "match.spInfo": "", "match.dispute.button": "", "match.dispute.p1": "", @@ -143,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": "", @@ -166,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 8a17bff3d..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": "", @@ -192,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/common.json b/locales/pt-BR/common.json index b09af5d46..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": "", @@ -319,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": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index d504d302c..8d3957031 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -9,6 +9,7 @@ "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": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 98bc4fa6b..3df8792a1 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -125,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.", @@ -143,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.", @@ -166,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 cf2dbe9d8..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.", @@ -190,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/common.json b/locales/ru/common.json index fc161b228..8ae3ea72d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -143,6 +143,7 @@ "actions.disable": "Выключить", "actions.accept": "Подтвердить", "actions.decline": "", + "actions.refuse": "", "actions.confirm": "", "actions.next": "", "actions.previous": "", @@ -320,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": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 8aec4df67..98c0040b3 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -9,6 +9,7 @@ "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды", "bottomTexts.tag": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index a63b4d310..41a4abf8e 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -125,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": "Если участниками другой команды была допущена ошибка, то попросите их исправить её. Пока обе команды не сообщат одинаковый счёт, то его можно свободно изменить.", @@ -143,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": "Вы сообщили счёт отличный от того, который сообщила другая команда. Проверьте достоверность счёта, иначе ознакомьтесь с инструкцией по оспорению на верху страницы.", @@ -166,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 310a2a95d..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": "Нет команды, соответствующей коду приглашения.", @@ -192,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/common.json b/locales/zh/common.json index 72ffeac0d..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": "", @@ -313,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": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index f2d49a5f9..1705f1dd7 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -9,6 +9,7 @@ "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", "labels.noScreen": "", + "labels.noSplatnet": "", "labels.spoilerFreeMode": "", "bottomTexts.name": "请注意,如果您更改了队名,那么其他人便可以使用之前的队名和URL了。", "bottomTexts.tag": "", diff --git a/locales/zh/q.json b/locales/zh/q.json index a09dcc17c..69b5e9355 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -125,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": "如果比分有误,请联系对手修改。比分可以重新提交,知道双方提交的比分相同为止。", @@ -143,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": "您提交的比分和对手不同。请再次确认比分是否正确,或查看页面顶部的分歧规则。", @@ -166,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 f4220d469..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": "没有队伍与邀请码相匹配。", @@ -184,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/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 d07a05d9b..90a5b55e7 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "openskill": "4.1.1", "p-limit": "7.3.0", "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", @@ -93,6 +94,7 @@ "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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ce497d97..09bc865c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: partysocket: 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 @@ -176,6 +179,9 @@ importers: 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 @@ -191,7 +197,7 @@ importers: version: 1.59.1 '@react-router/dev': 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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(yaml@2.8.4) + 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 @@ -215,7 +221,7 @@ importers: version: 3.6.4 '@vitest/browser-playwright': specifier: 4.1.5 - version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@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.5 version: 4.1.5(vitest@4.1.5) @@ -248,16 +254,16 @@ importers: version: 6.0.3 vite: specifier: 8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + 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.4) + 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.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + 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.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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + 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.5) @@ -684,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'} @@ -1864,98 +1714,98 @@ packages: '@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] @@ -1966,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] @@ -2616,8 +2460,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.24: - resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==} + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} engines: {node: '>=6.0.0'} hasBin: true @@ -2648,8 +2492,8 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - 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 @@ -2692,8 +2536,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001791: - resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2917,8 +2761,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.345: - resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==} + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2963,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'} @@ -3325,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'} @@ -3454,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==} @@ -3630,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==} @@ -3751,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 @@ -3834,6 +3678,11 @@ 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'} @@ -4046,8 +3895,8 @@ packages: 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 @@ -4130,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: @@ -4212,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==} @@ -4592,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'} @@ -5169,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: @@ -5179,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 @@ -5430,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': @@ -6190,7 +5978,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(yaml@2.8.4)': + '@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 @@ -6209,18 +5997,18 @@ snapshots: exit-hook: 2.2.1 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.1 - prettier: 3.8.3 + prettier: 3.8.1 react-refresh: 0.14.2 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.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + 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.2(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) @@ -6339,82 +6127,79 @@ snapshots: '@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': @@ -7084,29 +6869,29 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.5 - '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': + '@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.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.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) + '@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.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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + 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.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': + '@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.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.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.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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + 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 @@ -7123,13 +6908,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.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))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + 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': dependencies: @@ -7158,7 +6943,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - 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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + 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.5': dependencies: @@ -7244,7 +7029,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.24: {} + baseline-browser-mapping@2.10.11: {} basic-auth@2.0.1: dependencies: @@ -7288,13 +7073,13 @@ snapshots: bowser@2.14.1: {} - browserslist@4.28.2: + browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.24 - caniuse-lite: 1.0.30001791 - electron-to-chromium: 1.5.345 - 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: {} @@ -7339,7 +7124,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001791: {} + caniuse-lite@1.0.30001781: {} chai@6.2.2: {} @@ -7543,7 +7328,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.345: {} + electron-to-chromium@1.5.328: {} emoji-regex@8.0.0: {} @@ -7602,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: {} @@ -7975,6 +7731,8 @@ snapshots: jsesc@3.0.2: {} + jsesc@3.1.0: {} + json5@2.2.3: {} jsoncrush@1.1.8: {} @@ -8090,7 +7848,7 @@ snapshots: lodash.uniq@4.5.0: {} - lodash@4.18.1: {} + lodash@4.17.23: {} loose-envify@1.4.0: dependencies: @@ -8221,7 +7979,7 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-releases@2.0.38: {} + node-releases@2.0.36: {} nprogress@0.2.0: {} @@ -8373,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: @@ -8500,6 +8258,10 @@ 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 @@ -8742,35 +8504,35 @@ snapshots: '@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: {} @@ -8860,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 @@ -8884,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 @@ -8949,7 +8711,7 @@ snapshots: statuses@2.0.2: {} - std-env@4.1.0: {} + std-env@4.0.0: {} stream-browserify@3.0.0: dependencies: @@ -9105,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 @@ -9176,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.4): + 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.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + 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' @@ -9197,18 +8959,18 @@ snapshots: - tsx - yaml - vite-plugin-babel@1.6.0(@babel/core@7.29.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): + 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.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + 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.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.13 - rollup: 4.60.2 + rollup: 4.60.0 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 @@ -9218,7 +8980,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.4 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4): + 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 @@ -9227,7 +8989,7 @@ snapshots: 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 @@ -9237,15 +8999,15 @@ snapshots: dependencies: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - 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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + 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.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.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): + 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.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.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/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -9257,16 +9019,16 @@ snapshots: 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.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + 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.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@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 @@ -9277,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 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/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}`, ); }