New match page (#3032)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2026-05-04 18:15:10 +03:00 committed by GitHub
parent 8b78522b74
commit 2b5b1b1948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
345 changed files with 16409 additions and 8189 deletions

View File

@ -82,10 +82,10 @@ pnpm exec playwright show-trace test-results/<test-folder>/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

View File

@ -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

View File

@ -10,6 +10,7 @@
& > label {
margin: 0;
text-box: trim-start cap alphabetic;
}
}

View File

@ -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 (
<ReactAriaButton
data-testid={testId}
{...rest}
className={buttonClassName({ className, variant, size, shape })}
>

View File

@ -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 (
<Section>
{headerText ? (
<Header className={styles.menuHeader}>{headerText}</Header>
<Header className={clsx(styles.menuHeader, headerClassName)}>
{headerText}
</Header>
) : null}
{children}
</Section>

View File

@ -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,

View File

@ -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);
}

View File

@ -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<PickBanMapOption>();
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 (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.root}>
<div className={styles.title}>{t(titleKey)}</div>
<div className={styles.options}>
{layout === "STAGE_BY_MODE" ? (
<StageByModeGrid
options={options}
type={type}
selected={selected}
onSelect={setSelected}
disabled={isWaiting}
/>
) : layout === "STAGE_ONLY" ? (
<StageOnlyGrid
options={options}
type={type}
selected={selected}
onSelect={setSelected}
disabled={isWaiting}
/>
) : (
<ModeOnlyGrid
options={options}
type={type}
selected={selected}
onSelect={setSelected}
disabled={isWaiting}
/>
)}
</div>
{isWaiting ? (
<p className={styles.waiting}>
{t("q:match.action.pickBanWaiting", { teamName: waitingFor })}
</p>
) : (
<>
<p className={styles.prompt}>
{selectedLabel ? (
<>
<span
className={
type === "PICK" ? styles.verbPick : styles.verbBan
}
>
{type === "PICK"
? t("q:match.action.picking")
: t("q:match.action.banning")}
</span>{" "}
{selectedLabel}
</>
) : (
t("q:match.action.pickBanPrompt")
)}
</p>
<SendouButton
variant="primary"
className={styles.submit}
isDisabled={!selected || isSubmitting}
onPress={() => {
if (!selected) return;
onSubmit?.({ type, map: selected });
}}
testId="pick-ban-submit-button"
>
{t("common:actions.submit")}
</SendouButton>
</>
)}
</div>
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
</SendouTabPanel>
);
}
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<ModeShort, PickBanMapOption[]>();
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) => (
<div key={mode} className={styles.modeGroup}>
<Divider className={styles.divider}>
<ModeImage mode={mode} size={32} />
</Divider>
<div className={styles.stageGrid}>
{byMode.get(mode)!.map((option) => (
<StageTile
key={`${option.stageId}-${option.mode}`}
option={option}
type={type}
isSelected={isSameOption(option, selected)}
onSelect={() => onSelect(option)}
disabled={disabled}
/>
))}
</div>
</div>
))}
</>
);
}
function StageOnlyGrid({
options,
type,
selected,
onSelect,
disabled,
}: {
options: PickBanMapOption[];
type: "PICK" | "BAN";
selected?: PickBanMapOption;
onSelect: (option: PickBanMapOption) => void;
disabled?: boolean;
}) {
return (
<div className={styles.stageGrid}>
{options.map((option) => (
<StageTile
key={option.stageId}
option={option}
type={type}
isSelected={isSameOption(option, selected)}
onSelect={() => onSelect(option)}
disabled={disabled}
/>
))}
</div>
);
}
function ModeOnlyGrid({
options,
type,
selected,
onSelect,
disabled,
}: {
options: PickBanMapOption[];
type: "PICK" | "BAN";
selected?: PickBanMapOption;
onSelect: (option: PickBanMapOption) => void;
disabled?: boolean;
}) {
return (
<div className={styles.modeGrid}>
{options.map((option) => (
<ModeTile
key={option.mode}
option={option}
type={type}
isSelected={isSameOption(option, selected)}
onSelect={() => onSelect(option)}
disabled={disabled}
/>
))}
</div>
);
}
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 (
<div className={styles.tileContainer}>
<div className={styles.tileWrapper}>
<button
type="button"
className={clsx(styles.tile, styles.stageTile, {
[styles.tileSelected]: isSelected,
})}
style={{
"--map-image-url": `url("${stageImageUrl(option.stageId!)}.avif")`,
}}
onClick={onSelect}
disabled={disabled}
data-testid="pick-ban-button"
/>
{isSelected ? (
type === "PICK" ? (
<Check className={clsx(styles.tileIcon, styles.tileIconPick)} />
) : (
<X className={clsx(styles.tileIcon, styles.tileIconBan)} />
)
) : null}
{option.nth ? (
<span className={styles.tileNumber}>{option.nth}</span>
) : null}
</div>
<div className={styles.tileLabel}>
{shortStageName(t(`game-misc:STAGE_${option.stageId!}`))}
</div>
{option.picker ? (
<span
className={clsx(styles.tileFrom, {
"text-theme": option.picker === "BOTH",
"text-success": option.picker === "US",
"text-error": option.picker === "THEM",
})}
>
{option.picker === "US"
? t("q:match.action.pickerUs")
: option.picker === "THEM"
? t("q:match.action.pickerThem")
: t("q:match.action.pickerBoth")}
</span>
) : null}
</div>
);
}
function ModeTile({
option,
type,
isSelected,
onSelect,
disabled,
}: {
option: PickBanMapOption;
type: "PICK" | "BAN";
isSelected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation(["game-misc"]);
return (
<div className={styles.tileContainer}>
<div className={styles.tileWrapper}>
<button
type="button"
className={clsx(styles.tile, styles.modeTile, {
[styles.tileSelected]: isSelected,
})}
onClick={onSelect}
disabled={disabled}
data-testid="pick-ban-button"
>
<ModeImage mode={option.mode!} size={48} />
</button>
{isSelected ? (
type === "PICK" ? (
<Check className={clsx(styles.tileIcon, styles.tileIconPick)} />
) : (
<X className={clsx(styles.tileIcon, styles.tileIconBan)} />
)
) : null}
</div>
<div className={styles.tileLabel}>
{t(`game-misc:MODE_LONG_${option.mode!}`)}
</div>
</div>
);
}
function isSameOption(a: PickBanMapOption, b: PickBanMapOption | undefined) {
if (!b) return false;
return a.stageId === b.stageId && a.mode === b.mode;
}

View File

@ -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);
}

View File

@ -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<number | null>(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 (
<SendouTabPanel id={TAB_KEYS.ACTION}>
{confirming && winnerId !== null && setEnding ? (
<SetEndingConfirmation
setEnding={setEnding}
stageId={stageId}
mode={mode}
winnerId={winnerId}
teams={teams}
withPoints={withPoints}
isKo={isKo}
isSubmitting={isSubmitting}
onBack={() => setConfirming(false)}
onConfirm={submit}
/>
) : (
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
) : null}
<RadioGroup
value={winnerId !== null ? String(winnerId) : null}
onChange={(value) => {
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}
>
<TeamRadioOption
team={teams[0]}
isOwnTeam={teams[0].id === ownTeamId}
hideLabel={ownTeamId == null}
className={styles.alpha}
testId="winner-radio-1"
/>
<StageImage
stageId={stageId}
width={90}
className={styles.stageImage}
containerClassName={styles.stageImageWrapper}
/>
<div className={styles.stageLabel}>
<ModeImage mode={mode} size={14} />
<span>{shortStageName(t(`game-misc:STAGE_${stageId}`))}</span>
</div>
<TeamRadioOption
team={teams[1]}
isOwnTeam={teams[1].id === ownTeamId}
hideLabel={ownTeamId == null}
className={clsx(styles.bravo)}
testId="winner-radio-2"
/>
</RadioGroup>
{withPoints ? (
<div className={styles.ko}>
<label className={styles.koLabel}>
<input
type="checkbox"
checked={isKo}
onChange={(e) => setIsKo(e.target.checked)}
data-testid="ko-checkbox"
/>
{t("q:match.action.ko")}
</label>
</div>
) : null}
<SendouButton
variant="primary"
isDisabled={!canSubmit || isSubmitting}
onPress={() => {
if (winnerId === null) return;
if (setEnding?.setEndingTeamIds.includes(winnerId)) {
setConfirming(true);
} else {
submit();
}
}}
className={styles.submit}
testId="report-score-button"
>
{t("common:actions.submit")}
</SendouButton>
</div>
)}
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
</SendouTabPanel>
);
}
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 (
<div className={styles.confirmationRoot}>
<div className={styles.confirmationMessage}>
{t("q:match.action.confirmSetEnding")}
</div>
<MatchTimeline
teams={setEnding.teams}
score={updatedScore}
maps={[...setEnding.maps, newMap]}
/>
<div className={styles.confirmationButtons}>
<SendouButton
variant="primary"
isDisabled={isSubmitting}
onPress={onConfirm}
testId="confirm-set-end-button"
>
{t("common:actions.confirm")}
</SendouButton>
<SendouButton variant="outlined" onPress={onBack}>
{t("common:actions.back")}
</SendouButton>
</div>
</div>
);
}
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 (
<Radio
value={String(team.id)}
aria-label={team.name}
className={clsx(styles.teamRadioContainer, className)}
data-testid={testId}
>
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.teamRadio, {
[styles.selected]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<span
className={clsx(styles.checkCircle, {
[styles.checkCircleSelected]: isSelected,
})}
>
{isSelected ? <Check size={14} /> : null}
</span>
<span className={styles.teamAvatarInfo}>
<Avatar url={team.avatar} identiconInput={team.name} size="xxs" />
<span className={styles.teamInfo}>
<span
className={clsx(styles.teamName, {
[styles.teamNameLong]: isLongName,
})}
>
{team.name}
</span>
{hideLabel ? null : (
<span
className={clsx(styles.teamLabel, {
[styles.myTeamLabel]: isOwnTeam,
[styles.opponentLabel]: !isOwnTeam,
})}
>
{isOwnTeam
? t("q:match.action.myTeam")
: t("q:match.action.opponent")}
</span>
)}
</span>
</span>
</span>
)}
</Radio>
);
}

View File

@ -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);
}

View File

@ -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 <div className={styles.root}>{children}</div>;
}
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 (
<div
className={styles.banner}
style={{
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
}}
data-testid="stage-banner"
>
<div className={clsx(styles.map, styles.thickText)}>
<ModeImage mode={mode} size={24} />
{t(`game-misc:MODE_SHORT_${mode}`)} {t(`game-misc:STAGE_${stageId}`)}
</div>
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
</div>
);
}
export function MultiMatchBanner({ stageIds }: { stageIds: StageId[] }) {
return (
<div className={clsx(styles.banner, styles.multiBanner)}>
{stageIds.map((stageId, i) => (
<div
key={`${stageId}-${i}`}
className={styles.segment}
style={
{
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
} as React.CSSProperties
}
/>
))}
</div>
);
}
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 (
<div className={styles.iconBanner} data-testid={testId}>
{icon}
<div className={styles.iconBannerHeader}>{header}</div>
{subtitle ? (
<div className={styles.iconBannerSubtitle}>{subtitle}</div>
) : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
{topRight ? (
<div className={styles.iconBannerBottomRight}>{topRight}</div>
) : null}
</div>
);
}
function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
const { t } = useTranslation(["weapons", "q"]);
const imgSize = 18;
const Icon = screenLegal ? Check : X;
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
className={styles.notice}
testId={screenLegal ? "screen-allowed" : "screen-banned"}
>
<Icon
size={imgSize}
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
/>
<img
src={`${specialWeaponImageUrl(19)}.avif`}
width={imgSize}
height={imgSize}
alt=""
/>
</SendouButton>
}
>
{screenLegal
? t("q:match.screen.allowed", {
special: t("weapons:SPECIAL_19"),
})
: t("q:match.screen.ban", {
special: t("weapons:SPECIAL_19"),
})}
</SendouPopover>
);
}

View File

@ -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);
}

View File

@ -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 (
<div className={styles.root}>
<ModeProgress games={games} />
<ActiveRosters activeRosters={activeRosters} />
</div>
);
}
function ModeProgress({ games }: Pick<MatchBannerBottomRowProps, "games">) {
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 (
<div className={styles.modeProgress}>
<div
className={styles.mode}
data-testid={`mode-progress-${knownModes[0]}`}
>
<ModeImage mode={knownModes[0]} size={16} />
</div>
<div className={styles.modeCount}>×{games.length}</div>
</div>
);
}
return (
<div className={styles.modeProgress}>
{games.map((game, i) =>
game.mode ? (
<div
key={i}
className={styles.mode}
data-testid={`mode-progress-${game.mode}`}
>
<ModeImage mode={game.mode} size={16} />
</div>
) : (
<div
key={i}
className={clsx(styles.mode, styles.modePlaceholder)}
data-testid="mode-progress-banned"
>
<MousePointerClick size={16} />
</div>
),
)}
</div>
);
}
function Roster({ users }: { users: CommonUser[] }) {
return (
<div className={styles.team}>
{users.map((user) => (
<Avatar key={user.id} user={user} size="xxs" />
))}
</div>
);
}
function ActiveRosters({
activeRosters,
}: Pick<MatchBannerBottomRowProps, "activeRosters">) {
const { t } = useTranslation(["q"]);
if (!activeRosters?.alpha || !activeRosters.bravo) {
return null;
}
return (
<div className={styles.activeRosters}>
<Roster users={activeRosters.alpha} />
<div className={styles.vs}>{t("q:match.banner.vs")}</div>
<Roster users={activeRosters.bravo} />
</div>
);
}

View File

@ -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);
}

View File

@ -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 (
<div className={styles.root}>
<Score score={score} />
{time ? <Timer time={time} /> : null}
</div>
);
}
function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
const { t } = useTranslation(["q"]);
return (
<div className={styles.values}>
<div>
{score.alpha}-{score.bravo}
</div>
<div
className={styles.sub}
data-testid={score.isFinal ? "match-final" : undefined}
>
{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 })}
</div>
</div>
);
}
function Timer({
time,
}: {
time: NonNullable<MatchBannerTopRowProps["time"]>;
}) {
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 (
<div className={styles.values} data-testid="match-timer">
<time dateTime={dateTime(time.currentMinutes)} className={styles.sub}>
{displayValue(time.currentMinutes)}
</time>
<time dateTime={dateTime(time.totalMinutes)}>
{displayValue(time.totalMinutes)}
</time>
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<SendouTabPanel id={TAB_KEYS.JOIN}>
<div className="stack lg">
{showNoSplatnetAlert ? (
<Alert variation="WARNING" tiny>
{t("q:match.noSplatnetWarning")}
</Alert>
) : null}
<div className={styles.joinContent}>
{joinLink ? (
isStale ? (
<StaleRoomPrompt
minutesAgo={staleMinutesAgo ?? 0}
onConfirm={onConfirmRoom}
isConfirming={isConfirming}
/>
) : (
<>
{refreshedAt ? (
<div className={styles.roomAge}>
{formatDistanceToNow(refreshedAt, { addSuffix: true })}
</div>
) : null}
<div className={styles.qrCodeContainer}>
<QRCodeSVG
value={joinLink}
size={140}
className={styles.qrCode}
/>
<a
href={joinLink}
target="_blank"
rel="noopener noreferrer"
className={styles.joinLink}
>
{joinLink}
</a>
</div>
</>
)
) : (
<div className={clsx(styles.qrOverlay, styles.noRoomHint)}>
{t("q:match.room.noRoomHint")}
</div>
)}
<div className={styles.joinInfo}>
{hostedBy ? (
<InfoWithHeader header={t("q:match.hostedBy")} value={hostedBy} />
) : null}
<InfoWithHeader header={t("q:match.pool")} value={pool} />
<InfoWithHeader
header={t("q:match.password.short")}
value={pass}
testId="room-pass"
/>
</div>
</div>
</div>
</SendouTabPanel>
);
}
function StaleRoomPrompt({
minutesAgo,
onConfirm,
isConfirming,
}: {
minutesAgo: number;
onConfirm?: () => void;
isConfirming?: boolean;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={clsx(styles.qrOverlay, styles.stalePrompt)}>
<div className={styles.staleText}>
{t("q:match.room.stalePrompt", { minutes: minutesAgo })}
</div>
<SendouButton
variant="outlined"
size="small"
onPress={onConfirm}
isDisabled={isConfirming}
>
{t("q:match.room.confirm")}
</SendouButton>
</div>
);
}
function InfoWithHeader({
header,
value,
testId,
}: {
header: string;
value: string;
testId?: string;
}) {
return (
<div>
<div className={styles.infoHeader}>{header}</div>
<div className={styles.infoValue} data-testid={testId}>
{value}
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-6);
}

View File

@ -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 <div className={clsx(styles.root, className)}>{children}</div>;
}

View File

@ -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);
}

View File

@ -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 (
<div className={styles.root}>
<div>
<h2 className={styles.title}>{children}</h2>
<div className={styles.subtitle}>{subtitle}</div>
</div>
{topRight ? <div>{topRight}</div> : null}
</div>
);
}

View File

@ -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 (
<SendouTabPanel id={TAB_KEYS.RESULT}>
<MatchTimeline {...props} />
{children}
</SendouTabPanel>
);
}

View File

@ -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);
}
}

View File

@ -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<MainWeaponId>;
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<RosterTabMember>;
/** Sub user ids i.e. those who are not the current active roster */
subbedOut?: Array<number>;
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 (
<SendouTabPanel id={TAB_KEYS.ROSTERS}>
<div className={styles.rosters}>
<TeamRoster
team={teams[0]}
side="alpha"
canEditSubbedOut={canEditSubbedOut?.[0] ?? false}
defaultIsEditing={defaultIsEditing?.[0] ?? false}
minMembersPerTeam={minMembersPerTeam}
onSubbedOutChange={onSubbedOutChange}
isSubmitting={isSubmitting}
/>
<div className={styles.rostersDivider} />
<TeamRoster
team={teams[1]}
side="bravo"
canEditSubbedOut={canEditSubbedOut?.[1] ?? false}
defaultIsEditing={defaultIsEditing?.[1] ?? false}
minMembersPerTeam={minMembersPerTeam}
onSubbedOutChange={onSubbedOutChange}
isSubmitting={isSubmitting}
/>
</div>
</SendouTabPanel>
);
}
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<number[]>([]);
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 (
<div className={clsx("stack xxs", styles.rosterColumn)}>
<TeamHeader
team={team}
side={side}
label={label}
dotClassName={dotClassName}
/>
{team.members.length > 0 ? (
<ul className={styles.rosterMembers}>
{isEditing
? team.members.map((member, index) => (
<li key={member.id}>
<label className="stack horizontal sm items-center cursor-pointer">
<input
type="checkbox"
checked={selectedMemberIds.includes(member.id)}
onChange={() => handleToggleMember(member.id)}
data-testid={`player-checkbox-${side}-${index}`}
/>
<Avatar user={member} size="xxs" />
<span>{member.username}</span>
</label>
</li>
))
: activeMembers.map((member) => (
<li key={member.id} className={styles.memberGrid}>
<RosterMemberLink
member={member}
className={styles.memberLink}
/>
<div className={styles.memberTier}>
<MemberTierPopover tier={member.tier} />
</div>
<div className={styles.memberMetaArea}>
<MemberMeta
plusTier={member.plusTier}
weaponPool={member.weaponPool}
/>
</div>
</li>
))}
{!isEditing && subbedOutMembers.length > 0 ? (
<li>
<SubbedOutPopover members={subbedOutMembers} />
</li>
) : null}
</ul>
) : null}
{isEditing ? (
<div>
<div className={styles.rosterEditCount}>
{selectedMemberIds.length}/{minMembersPerTeam}
</div>
<div className={styles.rosterEditButtons}>
<SendouButton
variant="primary"
size="small"
isDisabled={
isSubmitting || selectedMemberIds.length !== minMembersPerTeam
}
onPress={handleSubmit}
testId={`save-active-roster-button-${side}`}
>
{t("common:actions.submit")}
</SendouButton>
{defaultIsEditing ? null : (
<SendouButton
variant="outlined"
size="small"
onPress={handleCancel}
>
{t("common:actions.cancel")}
</SendouButton>
)}
</div>
</div>
) : null}
{showEditButton ? (
<SendouButton
icon={<Edit />}
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")}
</SendouButton>
) : null}
</div>
);
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 (
<Link to={team.team.url} className="stack horizontal sm">
<Avatar
url={team.team.avatar}
identiconInput={team.team.name}
size="sm"
/>
<div className="stack justify-center line-height-tight">
<h2 className="text-main-forced font-bold">{team.team.name}</h2>
<div className="stack xs horizontal items-center text-lighter">
<div className={dotClassName} />
{label}
{tierText ? (
<>
<span></span>
<span className="text-capitalize">{tierText}</span>
</>
) : null}
</div>
</div>
</Link>
);
}
invariant(team.defaultName, "team or defaultName must be provided");
return (
<div className="stack horizontal sm">
<div className={styles.teamAvatar} data-side={side} />
<div className="stack justify-center line-height-tight">
<h2 className="text-main-forced font-bold">{team.defaultName}</h2>
<div className="stack xs horizontal items-center text-lighter">
<div className={dotClassName} />
{label}
{tierText ? (
<>
<span></span>
<span className="text-capitalize">{tierText}</span>
</>
) : null}
</div>
</div>
</div>
);
}
function MemberTierPopover({
tier,
}: {
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
}) {
if (!tier) return null;
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={styles.tierBadge}>
{tier === "CALCULATING" ? (
<Image
path={tierImageUrl("CALCULATING")}
alt=""
width={22}
height={22 * 0.8675}
/>
) : (
<TierImage tier={tier} width={22} />
)}
</SendouButton>
}
>
<MemberTierPopoverContent tier={tier} />
</SendouPopover>
);
}
function MemberTierPopoverContent({
tier,
}: {
tier: { name: TierName; isPlus: boolean } | "CALCULATING";
}) {
const { t } = useTranslation(["q"]);
if (tier === "CALCULATING") {
return (
<div className={styles.tierPopover}>
<Image
path={tierImageUrl("CALCULATING")}
alt=""
width={80}
height={80 * 0.8675}
/>
<span className={styles.tierPopoverName}>
{t("q:looking.sp.calculating")}
</span>
</div>
);
}
return (
<div className={styles.tierPopover}>
<TierImage tier={tier} width={80} />
<span className={styles.tierPopoverName}>
{tier.name.toLowerCase()}
{tier.isPlus ? "+" : ""}
</span>
</div>
);
}
function MemberMeta({
plusTier,
weaponPool,
}: {
plusTier?: number | null;
weaponPool?: Array<MainWeaponId>;
}) {
const hasPlusTier = typeof plusTier === "number";
const hasWeapons = weaponPool && weaponPool.length > 0;
if (!hasPlusTier && !hasWeapons) return null;
return (
<div className={styles.memberMeta}>
{hasPlusTier ? (
<div className={styles.plusTier}>
<Image path={navIconUrl("plus")} width={16} height={16} alt="" />
<span>{plusTier}</span>
</div>
) : null}
{hasWeapons ? <WeaponPool weapons={weaponPool} size={18} /> : null}
</div>
);
}
function SubbedOutPopover({ members }: { members: Array<RosterTabMember> }) {
const { t } = useTranslation(["q"]);
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" size="small" className="h-max">
<div className={styles.subbedOutTrigger}>
<div className={styles.subbedOutIcon}>
<Armchair size={16} />
</div>
+{members.length}
</div>
</SendouButton>
}
>
<div className={styles.subbedOutPopover}>
<div className={styles.subbedOutHeader}>{t("q:match.subbedOut")}</div>
{members.map((member) => (
<RosterMemberLink
key={member.id}
member={member}
className="stack horizontal sm items-center"
/>
))}
</div>
</SendouPopover>
);
}
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 = (
<div className={styles.memberNameStack}>
<span>{member.username}</span>
{showIgnUnderName ? (
<span className={styles.memberInGameName}>{member.inGameName}</span>
) : null}
</div>
);
if (!useMenu) {
return (
<Link to={userPage(member)} className={className}>
<Avatar user={member} size="xxs" />
{nameContent}
</Link>
);
}
const headerContent =
member.friendCode || showIgnInMenu ? (
<div className={styles.memberMenuHeader}>
{member.friendCode ? <span>{`SW-${member.friendCode}`}</span> : null}
{showIgnInMenu ? (
<span className={styles.memberMenuIgn}>
<span className={styles.memberMenuIgnLabel}>
{t("user:ign.short")}:
</span>{" "}
{member.inGameName}
</span>
) : null}
</div>
) : undefined;
return (
<SendouMenu
trigger={
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
<Avatar user={member} size="xxs" />
{nameContent}
</ReactAriaButton>
}
>
<SendouMenuSection
headerText={headerContent}
headerClassName={styles.friendCodeHeader}
>
<SendouMenuItem href={userPage(member)} icon={<User />}>
{t("friends:friendsList.viewUserPage")}
</SendouMenuItem>
{showNoteItem ? (
<SendouMenuItem
href={`?note=${member.id}`}
icon={
member.privateNote ? (
<img
src={preferenceEmojiUrl(
member.privateNote.sentiment === "POSITIVE"
? "PREFER"
: member.privateNote.sentiment === "NEGATIVE"
? "AVOID"
: undefined,
)}
alt=""
width={18}
height={18}
/>
) : (
<Edit />
)
}
>
{member.privateNote
? t("q:looking.groups.editNote")
: t("q:looking.groups.addNote")}
</SendouMenuItem>
) : null}
</SendouMenuSection>
</SendouMenu>
);
}

View File

@ -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);
}
}

View File

@ -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<MatchTabsKey>;
}
const TAB_KEY = "tab";
export const TAB_KEYS = {
ROSTERS: "rosters",
ACTION: "action",
JOIN: "join",
RESULT: "result",
ADMIN: "admin",
} as const;
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
rosters: <Users />,
action: <Tally5 />,
join: <DoorOpen />,
result: <ScrollText />,
admin: <Key />,
};
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 (
<div className={styles.root}>
<SendouTabs
selectedKey={currentTab}
onSelectionChange={(key) =>
setSearchParams(
{ [TAB_KEY]: key as string },
{
preventScrollReset: true,
unstable_defaultShouldRevalidate: false,
},
)
}
disappearing={false}
>
<SendouTabList>
{tabs.map((tab) => (
<SendouTab key={tab} id={tab} icon={TAB_ICONS[tab]}>
{t(TAB_TRANSLATION_KEYS[tab])}
</SendouTab>
))}
</SendouTabList>
{children}
</SendouTabs>
</div>
);
}

View File

@ -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);
}

View File

@ -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<MainWeaponId | null>;
bravo: Array<MainWeaponId | null>;
};
/** 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 (
<div className={styles.root}>
<TimelineHeader
teams={teams}
score={score}
maps={maps}
isOngoing={isOngoing}
/>
{compact
? null
: maps.map((map, i) => {
const previousMap = maps[i - 1];
const substitutions = previousMap
? inferSubstitutions(previousMap.rosters, map.rosters)
: [];
const pickBanRows = pickBanRowsBySlot?.[i] ?? [];
return (
<div key={i} className="contents">
{pickBanRows.map((event, j) => (
<TimelinePickBanRow key={`pb-${j}`} event={event} />
))}
{substitutions.map((sub, j) => (
<TimelineSubstitutionRow key={j} substitution={sub} />
))}
<TimelineMapRow map={map} />
</div>
);
})}
{!compact && pickBanRowsBySlot
? (pickBanRowsBySlot[maps.length] ?? []).map((event, j) => (
<TimelinePickBanRow key={`pb-trailing-${j}`} event={event} />
))
: null}
{!compact && spChanges ? (
<TimelineSpSection spChanges={spChanges} />
) : null}
</div>
);
}
function TimelineHeader({
teams,
score,
maps,
isOngoing,
}: Pick<MatchTimelineProps, "teams" | "score" | "maps" | "isOngoing">) {
const { t } = useTranslation(["q"]);
const initialRosters = maps[0]?.rosters;
return (
<div className={styles.header}>
<div className={styles.headerTeam}>
<div
className={clsx(styles.headerTeamName, {
[styles.headerTeamNameLong]:
teams.alpha.name.length > LONG_TEAM_NAME_THRESHOLD,
})}
>
{teams.alpha.name}
</div>
{initialRosters ? (
<div className={styles.headerAvatars}>
{initialRosters.alpha.map((user) => (
<Avatar key={user.id} user={user} size="xxs" />
))}
</div>
) : null}
</div>
<div className={styles.headerScore}>
<span className={styles.headerScoreValue}>
{score.alpha}-{score.bravo}
</span>
{isOngoing ? (
<span className={styles.headerScoreLive}>
{t("q:match.timeline.live")}
</span>
) : null}
</div>
<div className={clsx(styles.headerTeam, styles.headerTeamBravo)}>
<div
className={clsx(styles.headerTeamName, {
[styles.headerTeamNameLong]:
teams.bravo.name.length > LONG_TEAM_NAME_THRESHOLD,
})}
>
{teams.bravo.name}
</div>
{initialRosters ? (
<div className={styles.headerAvatars}>
{initialRosters.bravo.map((user) => (
<Avatar key={user.id} user={user} size="xxs" />
))}
</div>
) : null}
</div>
</div>
);
}
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 (
<div className={styles.mapEvent}>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
points={alphaPoints}
weapons={map.weapons?.alpha}
isPicked={map.pickedBy === "ALPHA"}
/>
</div>
<div className={styles.mapCenter}>
<time className={styles.mapTimestamp}>
{isHydrated ? (
formatTime(new Date(map.timestamp))
) : (
<div className="invisible">X</div>
)}
</time>
<StageImage
stageId={map.stageId}
width={80}
className={styles.mapStageImage}
/>
<div className={styles.mapLabel}>
<ModeImage mode={map.mode} size={14} />
<span>{shortStageName(t(`game-misc:STAGE_${map.stageId}`))}</span>
</div>
</div>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
points={bravoPoints}
weapons={map.weapons?.bravo}
isPicked={map.pickedBy === "BRAVO"}
/>
</div>
</div>
);
}
function SideResult({
result,
points,
weapons,
isPicked,
}: {
result: "WIN" | "LOSS";
points?: number;
weapons?: Array<MainWeaponId | null>;
isPicked?: boolean;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={styles.sideResult}>
<div className={styles.resultHeader}>
{isPicked ? (
<MousePointerClick
size={14}
className={result === "WIN" ? "text-success" : "text-error"}
aria-label={t("q:match.timeline.picked")}
/>
) : null}
<span
className={clsx(
styles.resultLabel,
result === "WIN" ? "text-success" : "text-error",
)}
>
{result === "WIN"
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{points === 100 ? (
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
) : null}
</div>
{weapons ? <WeaponPool weapons={weapons} /> : null}
</div>
);
}
function TimelineEventRow({
icon,
alphaContent,
bravoContent,
}: {
icon: React.ReactNode;
alphaContent: React.ReactNode;
bravoContent: React.ReactNode;
}) {
return (
<div className={styles.eventRow}>
<div className={styles.eventAlpha}>{alphaContent}</div>
<div className={styles.subCenter}>{icon}</div>
<div>{bravoContent}</div>
</div>
);
}
function TimelinePickBanRow({ event }: { event: TimelinePickBanEvent }) {
const isPick = event.kind === "PICK";
const icon = isPick ? (
<MousePointerClick
size={32}
className={clsx(styles.eventIcon, styles.pickIcon)}
/>
) : (
<X size={32} className={clsx(styles.eventIcon, styles.banIcon)} />
);
return (
<TimelineEventRow
icon={icon}
alphaContent={
event.alphaEntries.length > 0 ? (
<PickBanGroup entries={event.alphaEntries} side="ALPHA" />
) : null
}
bravoContent={
event.bravoEntries.length > 0 ? (
<PickBanGroup entries={event.bravoEntries} side="BRAVO" />
) : null
}
/>
);
}
function PickBanGroup({
entries,
side,
}: {
entries: Array<{ stageId?: StageId; mode?: ModeShort }>;
side: MatchSide;
}) {
return (
<div
className={clsx(styles.pickBanGroup, {
[styles.pickBanGroupBravo]: side === "BRAVO",
})}
>
{entries.map((entry, i) => (
<PickBanEntry key={i} entry={entry} />
))}
</div>
);
}
function PickBanEntry({
entry,
}: {
entry: { stageId?: StageId; mode?: ModeShort };
}) {
if (entry.stageId !== undefined) {
return (
<StageImage
stageId={entry.stageId}
width={56}
className={styles.pickBanStageImage}
/>
);
}
if (entry.mode !== undefined) {
return (
<div className={styles.pickBanModeTile}>
<ModeImage mode={entry.mode} size={24} />
</div>
);
}
return null;
}
function TimelineSubstitutionRow({
substitution,
}: {
substitution: InferredSubstitution;
}) {
return (
<TimelineEventRow
icon={<RefreshCcw size={32} className={styles.eventIcon} />}
alphaContent={
substitution.side === "ALPHA" ? (
<SubstitutionDetail substitution={substitution} />
) : null
}
bravoContent={
substitution.side === "BRAVO" ? (
<SubstitutionDetail substitution={substitution} />
) : null
}
/>
);
}
function SubstitutionDetail({
substitution,
}: {
substitution: InferredSubstitution;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={styles.subDetail}>
<span className={styles.subLabelOut}>{t("q:match.timeline.out")}</span>
<div className="stack horizontal items-center sm">
<Avatar user={substitution.playerOut} size="xxxs" />
<span className={styles.subPlayerName}>
{substitution.playerOut.username}
</span>
</div>
<span className={styles.subLabelIn}>{t("q:match.timeline.in")}</span>
<div className="stack horizontal items-center sm">
<Avatar user={substitution.playerIn} size="xxxs" />
<span className={styles.subPlayerName}>
{substitution.playerIn.username}
</span>
</div>
</div>
);
}
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 (
<div className={styles.spSection}>
<div className={styles.spColumn}>
{alphaMembersWithDiff.map((m) => (
<SpMemberDetail key={m.user.id} member={m} />
))}
{spChanges.alpha.skillDifference ? (
<SpTeamDetail skillDifference={spChanges.alpha.skillDifference} />
) : null}
</div>
<div className={styles.spIcon}>
<TrendingUp size={32} className={styles.eventIcon} />
</div>
<div className={styles.spColumn}>
{bravoMembersWithDiff.map((m) => (
<SpMemberDetail key={m.user.id} member={m} />
))}
{spChanges.bravo.skillDifference ? (
<SpTeamDetail skillDifference={spChanges.bravo.skillDifference} />
) : null}
</div>
</div>
);
}
function SpMemberDetail({ member }: { member: TimelineSpMember }) {
if (member.skillDifference.calculated) {
const { spDiff, oldSp, newSp } = member.skillDifference;
const isPositive = spDiff > 0;
const arrow = isPositive ? "▲" : "▼";
return (
<div className={styles.spDetail}>
<Avatar user={member.user} size="xxs" />
<SpDeltaTrigger
arrow={arrow}
isPositive={isPositive}
value={Math.abs(spDiff)}
oldSp={oldSp}
newSp={newSp}
/>
</div>
);
}
if (
member.skillDifference.matchesCount ===
member.skillDifference.matchesCountNeeded
) {
return (
<div className={styles.spDetail}>
<Avatar user={member.user} size="xxs" />
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>
{member.skillDifference.newSp ? (
<>{member.skillDifference.newSp}SP</>
) : null}
</span>
</div>
</div>
);
}
return (
<div className={styles.spDetail}>
<Avatar user={member.user} size="xxs" />
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>
{member.skillDifference.matchesCount}/
{member.skillDifference.matchesCountNeeded}
</span>
</div>
</div>
);
}
function SpTeamDetail({
skillDifference,
}: {
skillDifference: GroupSkillDifference;
}) {
if (skillDifference.calculated) {
const { oldSp, newSp } = skillDifference;
const diff = newSp - oldSp;
const isPositive = diff > 0;
const arrow = isPositive ? "▲" : "▼";
return (
<div className={styles.spDetail}>
<div className={styles.spTeamIcon}>
<Users size={16} />
</div>
<SpDeltaTrigger
arrow={arrow}
isPositive={isPositive}
value={Math.abs(diff)}
oldSp={oldSp}
newSp={newSp}
/>
</div>
);
}
if (skillDifference.newSp) {
return (
<div className={styles.spDetail}>
<div className={styles.spTeamIcon}>
<Users size={16} />
</div>
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>{skillDifference.newSp}SP</span>
</div>
</div>
);
}
return (
<div className={styles.spDetail}>
<div className={styles.spTeamIcon}>
<Users size={16} />
</div>
<div className={styles.spDetailContent}>
<span className={styles.spCalculatingIcon}></span>
<span>
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded}
</span>
</div>
</div>
);
}
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 (
<div className={styles.spDetailContent}>
<span className={arrowClass}>{arrow}</span>
<span>{value}SP</span>
</div>
);
}
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={styles.spDeltaTrigger}>
<span className={arrowClass}>{arrow}</span>
<span>{value}SP</span>
</SendouButton>
}
>
<div className={styles.spRawPopover}>
<span>{oldSp}SP</span>
<ArrowRight size={16} />
<span>{newSp}SP</span>
</div>
</SendouPopover>
);
}

View File

@ -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);
}

View File

@ -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<MainWeaponId | null>;
size?: number;
}) {
const { t } = useTranslation(["weapons"]);
return (
<SendouPopover
trigger={
<Button className={styles.weaponRow}>
{weapons.map((weaponId, i) =>
weaponId !== null ? (
<WeaponImage
key={i}
weaponSplId={weaponId}
variant="badge"
size={size}
/>
) : (
<Image
key={i}
className={styles.unknownWeapon}
path="/static-assets/img/abilities/UNKNOWN"
alt="?"
size={size}
/>
),
)}
</Button>
}
>
<div className={styles.weaponPopover}>
{weapons.map((weaponId, i) =>
weaponId !== null ? (
<div key={i} className={styles.weaponPopoverRow}>
<WeaponImage weaponSplId={weaponId} variant="badge" size={32} />
<span>{t(`weapons:MAIN_${weaponId}` as any)}</span>
</div>
) : null,
)}
</div>
</SendouPopover>
);
}

View File

@ -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;
}
}

View File

@ -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<MainWeaponId | null>(
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 (
<div className={styles.rootCollapsed}>
<SendouButton
variant="minimal"
size="small"
icon={<Crosshair size={16} />}
onPress={() => handleToggle(true)}
>
{t("q:match.actions.reportWeapons")}
</SendouButton>
</div>
);
}
return (
<div
className={clsx(styles.root, styles.rootExpanded, {
[styles.rootStandalone]: standalone,
})}
>
{standalone ? null : (
<SendouButton
variant="minimal"
size="miniscule"
icon={<ChevronUp size={22} />}
onPress={() => handleToggle(false)}
className={styles.collapseButton}
aria-label={t("q:match.actions.reportWeapons")}
/>
)}
{inputTargetMap ? (
<div className={styles.mapRow}>
<MapInfo map={inputTargetMap} />
<div className={styles.inputRow}>
<div className={styles.weaponSelectContainer}>
<WeaponSelect
label={`${t("q:match.weapon.yourWeapon")} #${nextMapIndex + 1}`}
value={selectedWeapon}
onChange={setSelectedWeapon}
quickSelectWeaponsIds={quickSelectWeaponIds}
/>
</div>
<SendouButton
variant="primary"
isDisabled={selectedWeapon === null || isSubmitting}
onPress={() => {
if (selectedWeapon === null) return;
onSubmit(selectedWeapon);
setSelectedWeapon(null);
}}
>
{t("common:actions.submit")}
</SendouButton>
</div>
</div>
) : null}
{pastReported.length > 0 ? (
<div className={styles.pastRow}>
{pastReported.map((weaponId, i) => (
<WeaponImage
key={i}
weaponSplId={weaponId}
variant="badge"
size={24}
/>
))}
<SendouButton
variant="minimal"
size="small"
isDisabled={isSubmitting}
onPress={onUndo}
>
{t("q:match.weapon.undoWeapon")}
</SendouButton>
</div>
) : null}
{unreportedCount > 0 ? (
<div className={styles.unreportedRow}>
{Array.from({ length: unreportedCount }, (_, i) => (
<Image
key={i}
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={24}
/>
))}
</div>
) : null}
</div>
);
}
function MapInfo({ map }: { map: WeaponReporterMap }) {
return (
<div className={styles.mapInfo}>
<StageImage
stageId={map.stageId}
width={100}
className={styles.stageImage}
/>
</div>
);
}

View File

@ -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 `<WeaponReporter />` 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" },
);
},
};
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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;

View File

@ -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<string>();
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<number, Map<ModeShort, Set<StageId>>>();
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(),
};

View File

@ -237,6 +237,8 @@ export interface Group {
id: GeneratedAlways<number>;
inviteCode: string;
latestActionAt: Generated<number>;
/** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */
matchmade: Generated<DBBoolean>;
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<number>;
id: GeneratedAlways<number>;
memento: JSONColumnTypeNullable<ParsedMemento>;
reportedAt: number | null;
reportedByUserId: number | null;
cancelRequestedByUserId: number | null;
cancelAcceptedByUserId: number | null;
}
export interface GroupMatchContinueVote {
id: GeneratedAlways<number>;
groupId: number;
userId: number;
isContinuing: DBBoolean;
votedAt: Generated<number>;
}
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<QWeaponPool[]>;
plusSkippedForSeasonNth: number | null;
noScreen: Generated<DBBoolean>;
/** User doesn't have access to SplatNet 3 to join rooms made by others */
noSplatnet: Generated<DBBoolean>;
buildSorting: JSONColumnTypeNullable<BuildSort[]>;
preferences: JSONColumnTypeNullable<UserPreferences>;
/** User creation date. Can be null because we did not always save this. */
@ -1306,6 +1327,13 @@ export interface NotificationUserSubscription {
subscription: JSONColumnType<NotificationSubscription>;
}
export interface RoomLink {
userId: number;
url: string;
createdAt: Generated<number>;
refreshedAt: Generated<number>;
}
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;

View File

@ -9,4 +9,5 @@ export const SEED_VARIATIONS = [
"TEAM_MAP_PREFS",
"FINALIZED_BRACKET",
"AB_RR",
"IN_SQ_MATCH",
] as const;

View File

@ -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";

View File

@ -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";

View File

@ -276,7 +276,7 @@ function ChatProviderInner({
"system:",
isSystemMessage,
);
if (isSystemMessage) {
if (isSystemMessage || messageArr[0].revalidateOnly) {
revalidate();
}

View File

@ -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();
}

View File

@ -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();
});
});

View File

@ -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);
}

View File

@ -9,6 +9,7 @@ export type SystemMessageType =
| "SCORE_CONFIRMED"
| "CANCEL_REPORTED"
| "CANCEL_CONFIRMED"
| "CANCEL_REFUSED"
| "TOURNAMENT_UPDATED"
| "TOURNAMENT_MATCH_UPDATED";

View File

@ -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);

View File

@ -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 ? (
<MessageContents text={message.contents} />
) : null}
</div>
</div>
</li>
@ -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(
<a
key={i}
href={match.url}
target="_blank"
rel="noopener noreferrer"
className={styles.roomLink}
>
{match.url}
</a>,
);
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);

View File

@ -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<RoomLink>;
/** 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",
};
}

View File

@ -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;
};

View File

@ -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",

View File

@ -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<ActionVariant>("winner");
return (
<Main>
<MatchPage>
<MatchPageHeader
subtitle="Mola Mola"
topRight={
<SendouButton variant="outlined" size="small" icon={<ArrowLeft />}>
Back to bracket
</SendouButton>
}
>
Round 2.1
</MatchPageHeader>
<SendouTabs
selectedKey={actionVariant}
onSelectionChange={(key) => setActionVariant(key as ActionVariant)}
disappearing={false}
padded={false}
>
<SendouTabList>
<SendouTab id="winner">Winner</SendouTab>
<SendouTab id="counterpick-stage">Counterpick</SendouTab>
<SendouTab id="ban-stage">Ban stage</SendouTab>
<SendouTab id="ban-stage-only">Ban stage (any mode)</SendouTab>
<SendouTab id="pick-mode">Pick mode</SendouTab>
<SendouTab id="ban-mode">Ban mode</SendouTab>
</SendouTabList>
</SendouTabs>
<MatchBannerContainer>
<MatchBannerTopRow
score={{
alpha: 1,
bravo: 2,
isFinal: false,
count: 5,
bestOf: true,
}}
time={{
currentMinutes: 3,
totalMinutes: 1,
}}
/>
<IconBanner
icon={<Ban size={32} />}
header={t("q:match.cancelRequested")}
subtitle={t("q:match.cancelRequested.subtitle", {
teamName: "Chimera",
})}
screenLegal={false}
/>
<MatchBannerBottomRow
games={[
{ mode: "SZ", winner: "ALPHA" },
{ mode: "TC", winner: "BRAVO" },
{ mode: "RM", winner: "ALPHA" },
]}
activeRosters={{
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
}}
/>
</MatchBannerContainer>
<MatchTabs tabs={["join", "rosters", "action", "result"]}>
<MatchJoinTab
joinLink="https://app.nintendo.net/private_battle/abc123"
pool="SQ7"
pass="8430"
showNoSplatnetAlert
/>
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[true, false]}
onSubbedOutChange={(teamId, subbedOut) => {
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" ? (
<MatchActionTab
teams={[
{ id: 1, name: "Chimera" },
{ id: 2, name: "Koopa Clan" },
]}
ownTeamId={1}
stageId={4}
mode="SZ"
withPoints={true}
actionButtons={
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
>
{t("q:match.undoReport")}
</SendouButton>
}
/>
) : actionVariant === "counterpick-stage" ? (
<MatchActionPickBanTab
type="PICK"
options={[
{ stageId: 1, mode: "SZ", picker: "US" },
{ stageId: 2, mode: "SZ", picker: "BOTH" },
{ stageId: 3, mode: "SZ", picker: "THEM" },
{ stageId: 4, mode: "TC", picker: "US" },
{ stageId: 5, mode: "TC", picker: "THEM" },
{ stageId: 6, mode: "RM", picker: "BOTH" },
{ stageId: 7, mode: "RM", picker: "US" },
]}
onSubmit={(data) => logger.info("pick submit", data)}
/>
) : actionVariant === "ban-stage" ? (
<MatchActionPickBanTab
type="BAN"
options={[
{ stageId: 1, mode: "SZ", nth: 1 },
{ stageId: 2, mode: "SZ", nth: 2 },
{ stageId: 4, mode: "TC", nth: 3 },
{ stageId: 5, mode: "TC", nth: 4 },
{ stageId: 6, mode: "RM", nth: 5 },
{ stageId: 7, mode: "RM", nth: 6 },
{ stageId: 8, mode: "CB", nth: 7 },
{ stageId: 9, mode: "CB", nth: 8 },
]}
onSubmit={(data) => logger.info("ban submit", data)}
/>
) : actionVariant === "ban-stage-only" ? (
<MatchActionPickBanTab
type="BAN"
options={[
{ stageId: 1 },
{ stageId: 2 },
{ stageId: 3 },
{ stageId: 4 },
{ stageId: 5 },
{ stageId: 6 },
{ stageId: 7 },
{ stageId: 8 },
{ stageId: 9 },
]}
onSubmit={(data) => logger.info("ban stage-only submit", data)}
/>
) : actionVariant === "pick-mode" ? (
<MatchActionPickBanTab
type="PICK"
options={[
{ mode: "SZ" },
{ mode: "TC" },
{ mode: "RM" },
{ mode: "CB" },
]}
onSubmit={(data) => logger.info("pick mode submit", data)}
/>
) : (
<MatchActionPickBanTab
type="BAN"
options={[
{ mode: "SZ" },
{ mode: "TC" },
{ mode: "RM" },
{ mode: "CB" },
]}
onSubmit={(data) => logger.info("ban mode submit", data)}
/>
)}
<MatchResultTab
teams={{
alpha: { name: "me in japan" },
bravo: { name: "Group Bravo" },
}}
score={{ alpha: 3, bravo: 0 }}
spChanges={{
alpha: {
members: [
{
user: {
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
skillDifference: {
calculated: true,
spDiff: 12.3,
oldSp: 1402.43,
newSp: 1414.73,
},
},
{
user: {
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: 8.7,
oldSp: 1521.18,
newSp: 1529.88,
},
},
{
user: {
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: false,
matchesCount: 3,
matchesCountNeeded: 7,
},
},
{
user: {
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: false,
matchesCount: 7,
matchesCountNeeded: 7,
newSp: 1850,
},
},
],
skillDifference: {
calculated: false,
matchesCount: 5,
matchesCountNeeded: 7,
},
},
bravo: {
members: [
{
user: {
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -11.2,
oldSp: 1612.55,
newSp: 1601.35,
},
},
{
user: {
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -9.4,
oldSp: 1488.62,
newSp: 1479.22,
},
},
{
user: {
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -13.8,
oldSp: 1730.91,
newSp: 1717.11,
},
},
{
user: {
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
skillDifference: {
calculated: true,
spDiff: -7.6,
oldSp: 1555.04,
newSp: 1547.44,
},
},
],
skillDifference: {
calculated: true,
oldSp: 1980,
newSp: 1968,
},
},
}}
maps={[
{
stageId: 1,
mode: "SZ",
timestamp: 1712855000,
winner: "ALPHA",
weapons: {
alpha: [40, 10, 1100, 3040],
bravo: [50, 210, 2010, 4010],
},
rosters: {
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
},
},
{
stageId: 4,
mode: "TC",
timestamp: 1712855600,
winner: "ALPHA",
weapons: {
alpha: [40, 10, 1100, 3040],
bravo: [50, 210, 2010, 4010],
},
rosters: {
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 7,
username: "Zack",
discordId: "901",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
},
},
{
stageId: 2,
mode: "RM",
timestamp: 1712856200,
winner: "ALPHA",
points: [100, 42],
weapons: {
alpha: [40, null, 1100, 3040],
bravo: [null, 210, null, 4010],
},
rosters: {
alpha: [
{
id: 1,
username: "Sendou",
discordId: "123",
discordAvatar: null,
customUrl: "sendou",
},
{
id: 2,
username: "Lean",
discordId: "456",
discordAvatar: null,
customUrl: null,
},
{
id: 3,
username: "Kiver",
discordId: "789",
discordAvatar: null,
customUrl: null,
},
{
id: 4,
username: "Brian",
discordId: "012",
discordAvatar: null,
customUrl: null,
},
],
bravo: [
{
id: 5,
username: "Naga",
discordId: "345",
discordAvatar: null,
customUrl: null,
},
{
id: 6,
username: "Grey",
discordId: "678",
discordAvatar: null,
customUrl: null,
},
{
id: 9,
username: "Poppy",
discordId: "567",
discordAvatar: null,
customUrl: null,
},
{
id: 8,
username: "Lime",
discordId: "234",
discordAvatar: null,
customUrl: null,
},
],
},
},
]}
/>
</MatchTabs>
</MatchPage>
</Main>
);
}

View File

@ -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",
"=",

View File

@ -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<typeof loader>();
const screenLegal = !data.anyUserPrefersNoScreen;
if (data.post.canceled) {
return (
<MatchBannerContainer>
<IconBanner
icon={<Ban size={32} />}
header={t("scrims:banner.canceled.header", {
user: data.post.canceled.byUser.username,
})}
subtitle={t("scrims:banner.canceled.subtitle", {
reason: data.post.canceled.reason,
})}
/>
</MatchBannerContainer>
);
}
const hasMaps = data.post.maps || data.tournamentMapPool;
return (
<MatchBannerContainer>
<IconBanner
icon={<Swords size={32} />}
header={t("scrims:banner.freeForm.header")}
subtitle={t("scrims:banner.freeForm.subtitle")}
screenLegal={screenLegal}
topRight={
hasMaps ? (
<MapsLink
maps={data.post.maps}
tournamentMapPool={data.tournamentMapPool}
/>
) : undefined
}
/>
</MatchBannerContainer>
);
}
function MapsLink({
maps,
tournamentMapPool,
}: Pick<ScrimPost, "maps"> &
Pick<SerializeFrom<typeof loader>, "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 (
<Link to={mapsPageWithMapPool(mapPool())}>
<Image
path={navIconUrl("maps")}
width={32}
height={32}
alt="Generate maplist"
/>
</Link>
);
}

View File

@ -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<typeof loader>();
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 (
<MatchPageHeader
subtitle={t("scrims:page.scheduledScrim")}
topRight={
canCancel ? (
<SendouDialog
trigger={
<SendouButton size="small" variant="minimal-destructive">
{t("common:actions.cancel")}
</SendouButton>
}
heading={t("scrims:cancelModal.scrim.title")}
showCloseButton
>
<CancelScrimForm />
</SendouDialog>
) : undefined
}
>
<TimePopover
time={databaseTimestampToDate(scrimTime)}
options={{
weekday: "short",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="text-left"
/>
</MatchPageHeader>
);
}
function CancelScrimForm() {
return (
<SendouForm
schema={cancelScrimSchema}
submitButtonTestId="cancel-scrim-submit"
>
{({ FormField }) => <FormField name="reason" />}
</SendouForm>
);
}

View File

@ -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<typeof loader>();
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 (
<MatchTabs tabs={[TAB_KEYS.JOIN, TAB_KEYS.ROSTERS]}>
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}
isConfirming={isConfirming}
pool={Scrim.resolvePoolCode(data.post.id)}
pass={resolveRoomPass(data.post.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
<MatchRosterTab
minMembersPerTeam={4}
teams={[
{
team: mapTeam(data.post.team),
defaultName: t("q:match.groupAlpha"),
members: data.post.users,
},
{
team: mapTeam(acceptedRequest.team),
defaultName: t("q:match.groupBravo"),
members: acceptedRequest.users,
},
]}
/>
</MatchTabs>
);
}
function mapTeam(team: ScrimPost["team"]) {
if (!team) return undefined;
return {
id: 0,
name: team.name,
url: teamPage(team.customUrl),
avatar: team.avatarUrl ?? undefined,
};
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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<typeof loader>();
const allowedToCancel = useHasPermission(data.post, "CANCEL");
const isCanceled = Boolean(data.post.canceled);
const canCancel =
allowedToCancel &&
!isCanceled &&
databaseTimestampToDate(data.post.at) > new Date();
return (
<Main className="stack lg">
<div className="stack horizontal justify-between">
<ScrimHeader />
{canCancel && (
<div>
<SendouDialog
trigger={
<SendouButton size="small" variant="minimal-destructive">
{t("common:actions.cancel")}
</SendouButton>
}
heading={t("scrims:cancelModal.scrim.title")}
showCloseButton
>
<CancelScrimForm />
</SendouDialog>
</div>
)}
</div>
{data.post.canceled && (
<div className="mx-auto">
<Alert variation="WARNING">
{t("scrims:alert.canceled", {
user: data.post.canceled.byUser.username,
reason: data.post.canceled.reason,
})}
</Alert>
</div>
)}
<div className={styles.groupsContainer}>
<GroupCard group={data.post} side="ALPHA" />
<GroupCard group={data.post.requests[0]} side="BRAVO" />
</div>
<div className="stack horizontal lg justify-center">
<InfoWithHeader
header={t("q:match.password.short")}
value={resolveRoomPass(data.post.id)}
/>
<InfoWithHeader
header={t("q:match.pool")}
value={Scrim.resolvePoolCode(data.post.id)}
/>
<ScreenBanIndicator />
{data.post.maps || data.tournamentMapPool ? (
<MapsLink
maps={data.post.maps}
tournamentMapPool={data.tournamentMapPool}
/>
) : null}
</div>
<Main>
<MatchPage>
<ScrimMatchHeader />
<ScrimMatchBanner />
<ScrimMatchTabs />
</MatchPage>
</Main>
);
}
function CancelScrimForm() {
return (
<SendouForm
schema={cancelScrimSchema}
submitButtonTestId="cancel-scrim-submit"
>
{({ FormField }) => <FormField name="reason" />}
</SendouForm>
);
}
function ScrimHeader() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
const scrimTime = acceptedRequest?.at ?? data.post.at;
return (
<div className="line-height-tight" data-testid="match-header">
<h2 className="text-lg">
<TimePopover
time={databaseTimestampToDate(scrimTime)}
options={{
weekday: "long",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="text-left"
/>
</h2>
<div className="text-lighter text-xs font-bold">
{t("scrims:page.scheduledScrim")}
</div>
</div>
);
}
function GroupCard({
group,
side,
}: {
group: { users: ScrimPostType["users"]; team: ScrimPostType["team"] };
side: "ALPHA" | "BRAVO";
}) {
const { t } = useTranslation(["q"]);
return (
<div className="stack sm">
<div className="stack horizontal justify-between">
<div className="text-lighter text-xs">
{side === "ALPHA"
? t("q:match.sides.alpha")
: t("q:match.sides.bravo")}
</div>
{group.team ? (
<Link
to={teamPage(group.team.customUrl)}
className="stack horizontal items-center xs font-bold text-xs"
>
<Avatar url={group.team.avatarUrl ?? BLANK_IMAGE_URL} size="xxs" />
{group.team.name}
</Link>
) : null}
</div>
<div className={styles.groupCard}>
{group.users.map((user) => (
<Link to={userPage(user)} key={user.id} className={styles.memberRow}>
<Avatar user={user} size="xs" />
{user.username}
</Link>
))}
</div>
</div>
);
}
function InfoWithHeader({ header, value }: { header: string; value: string }) {
return (
<div>
<div className={styles.infoHeader}>{header}</div>
<div className={styles.infoValue}>{value}</div>
</div>
);
}
function ScreenBanIndicator() {
const { t } = useTranslation(["weapons", "scrims"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<div className={styles.infoHeader}>{t("scrims:screenBan.header")}</div>
<div
className={clsx(styles.screenBanIndicator, {
[styles.screenBanIndicatorWarning]: data.anyUserPrefersNoScreen,
})}
>
<SendouPopover
trigger={
<SendouButton variant="minimal" size="miniscule">
<div className={styles.screenBanImageWrapper}>
<Image
path={specialWeaponImageUrl(SPLATTERCOLOR_SCREEN_ID)}
width={32}
height={32}
alt={t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)}
/>
<div className={styles.screenBanIconOverlay}>
{data.anyUserPrefersNoScreen ? <OctagonAlert /> : <Check />}
</div>
</div>
</SendouButton>
}
>
<div className="text-xs">
{data.anyUserPrefersNoScreen
? t("scrims:screenBan.warning")
: t("scrims:screenBan.allowed")}
</div>
</SendouPopover>
</div>
</div>
);
}
function MapsLink({
maps,
tournamentMapPool,
}: Pick<ScrimPost, "maps"> &
Pick<SerializeFrom<typeof loader>, "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 (
<div>
<div className={styles.infoHeader}>{t("scrims:maps.header")}</div>
<Link to={mapsPageWithMapPool(mapPool())}>
<Image
path={navIconUrl("maps")}
width={32}
height={32}
alt="Generate maplist"
/>
</Link>
</div>
);
}

View File

@ -59,6 +59,7 @@ export interface ScrimPostRequest {
export interface ScrimPostUser extends CommonUser {
isOwner: boolean;
inGameName: string | null;
}
interface ScrimPostTeam {

View File

@ -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);
});
});

View File

@ -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<DB>) {
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<DB>,
) {
const executor = trx ?? db;
const runner = async (t: Transaction<DB>) => {
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);
}

View File

@ -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<DB>,
) {
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<Array<{ weaponSplId: MainWeaponId; count: number }>> {
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<number>().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<number>().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<number>("merged.count").as("count"),
])
.groupBy("merged.weaponSplId")
.orderBy("count", "desc")
.execute();
return rows;
}

View File

@ -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({

View File

@ -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<DB, "Skill">) => {
.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<DB>,
) {
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<DB>,
@ -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<void> {
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<ReportScoreResult> {
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<RequestCancelResult> {
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<AcceptCancelResult> {
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<RefuseCancelResult> {
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<ReportMapWinnerResult> {
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<Awaited<ReturnType<typeof findById>>>;
winnerId: number;
reportedByUserId: number;
existingAlphaWins: number;
mapsToWin: number;
}): Promise<ReportMapWinnerResult> {
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<Awaited<ReturnType<typeof findById>>>;
currentMap: NonNullable<
Awaited<ReturnType<typeof findById>>
>["mapList"][number];
winnerId: number;
reportedByUserId: number;
}): Promise<ReportMapWinnerResult> {
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<Awaited<ReturnType<typeof findById>>>;
members: ReturnType<typeof buildMembers>;
winners: ("ALPHA" | "BRAVO")[];
winnerGroupId: number;
loserGroupId: number;
confirmedByUserId: number;
preFinalize?: (trx: Transaction<DB>) => Promise<unknown>;
}) {
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<Awaited<ReturnType<typeof findById>>>,
) {
@ -854,3 +1207,15 @@ function buildMembers(
})),
];
}
function lastReporterGroupId(
match: NonNullable<Awaited<ReturnType<typeof findById>>>,
members: ReturnType<typeof buildMembers>,
) {
const lastReportedMap = match.mapList
.toReversed()
.find((m) => m.reportedByUserId !== null);
if (!lastReportedMap?.reportedByUserId) return undefined;
return members.find((m) => m.id === lastReportedMap.reportedByUserId)
?.groupId;
}

View File

@ -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<ChatMessage["type"]> =
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<ChatMessage["type"]> =
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;
};

View File

@ -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<SendouQMatchLoaderData["match"]["groupAlpha"]>;
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 <DeclinedSection />;
}
// During awaiting confirmation, only reporter team can cascade.
if (awaitingConfirmation && !isOnReporterTeam) return null;
return (
<RematchVotePanel
members={viewerGroup.members.map((m) => ({
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<SendouQMatchLoaderData["match"]["groupAlpha"]>;
viewerUserId: number;
}) {
const { t } = useTranslation(["q"]);
const viewerRole = viewerGroup.members.find(
(m) => m.id === viewerUserId,
)?.role;
const lookAgainFetcher = useFetcher();
if (viewerRole === "OWNER") {
return (
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={lookAgainFetcher.state !== "idle"}
onPress={() => {
lookAgainFetcher.submit(
{
_action: "LOOK_AGAIN",
previousGroupId: String(viewerGroup.id),
},
{ method: "post" },
);
}}
>
{t("q:match.actions.lookAgain")}
</SendouButton>
</div>
);
}
return (
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.waitingCaptain")}
</p>
);
}
function DeclinedSection() {
const { t } = useTranslation(["q"]);
return (
<div className="stack md items-center">
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.declined")}
</p>
<Link to={SENDOUQ_PAGE} className="text-sm">
{t("q:match.rematch.rejoinQueue")}
</Link>
</div>
);
}

View File

@ -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;
}

View File

@ -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<any>;
};
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 (
<div className={styles.root}>
<div className={styles.prompt}>
{voteFailed
? t("q:match.rematch.fizzled")
: voteResolved
? t("q:match.rematch.resolved", { count: currentRoundSize })
: t("q:match.rematch.prompt", { count: currentRoundSize })}
</div>
<ul className={styles.list}>
{members.map((member) => {
const status = RejoinVote.userContinueStatus(votes, member.id);
return (
<li key={member.id} className={styles.row}>
<Avatar user={member} size="xxs" />
<span className={styles.username}>{member.username}</span>
<StatusIcon status={status} />
</li>
);
})}
</ul>
{voteResolved && viewerVotedYes ? (
<div className={styles.buttons}>
<Link to={SENDOUQ_LOOKING_PAGE}>
<SendouButton variant="primary" size="small" icon={<RotateCcw />}>
{t("q:match.rematch.backToQueue")}
</SendouButton>
</Link>
</div>
) : voteFailed || viewerVotedNo ? null : (
<div className={styles.buttons}>
<FormWithConfirm
fields={[
["_action", "CAST_CONTINUE_VOTE"],
["isContinuing", "0"],
]}
dialogHeading={t("q:match.rematch.vote.noConfirm")}
submitButtonText={t("q:match.rematch.vote.no")}
fetcher={fetcher}
>
<SendouButton
variant="outlined"
size="small"
isDisabled={isPending}
>
{t("q:match.rematch.vote.no")}
</SendouButton>
</FormWithConfirm>
<SendouButton
variant="primary"
size="small"
isDisabled={isPending || viewerVotedYes}
onPress={() =>
fetcher.submit(
{
_action: "CAST_CONTINUE_VOTE",
isContinuing: "1",
},
{ method: "post" },
)
}
>
{t("q:match.rematch.vote.yes")}
</SendouButton>
</div>
)}
</div>
);
}
function StatusIcon({ status }: { status: boolean | null }) {
if (status === true) {
return (
<Check size={18} className={styles.iconYes} aria-label="voted yes" />
);
}
if (status === false) {
return <X size={18} className={styles.iconNo} aria-label="voted no" />;
}
return (
<Clock size={18} className={styles.iconPending} aria-label="pending" />
);
}

View File

@ -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%;
}

View File

@ -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 <CancelPendingTab />;
}
if (
!awaitingConfirmation &&
!isLocked &&
!isStaffOnly &&
cancelRequestedByGroupId != null &&
cancelRequestedByGroupId !== ownTeamId
) {
return <CancelRespondTab />;
}
if (isLocked) {
return (
<RequeueTab
data={data}
viewerSide={viewerSide}
isStaffOnly={isStaffOnly}
awaitingConfirmation={false}
/>
);
}
if (awaitingConfirmation) {
return (
<RequeueTab
data={data}
viewerSide={viewerSide}
isStaffOnly={isStaffOnly}
awaitingConfirmation={true}
/>
);
}
if (currentMap) {
return (
<InProgressTab
data={data}
currentMap={currentMap}
ownTeamId={ownTeamId}
reportedCount={reportedCount}
user={user}
/>
);
}
return null;
}
function CancelPendingTab() {
const { t } = useTranslation(["q"]);
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.cancelWaiting}>
{t("q:match.cancelPendingConfirmation")}
</div>
</SendouTabPanel>
);
}
function CancelRespondTab() {
const { t } = useTranslation(["q", "common"]);
const cancelFetcher = useFetcher();
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.cancelRespondRoot}>
<div className={styles.cancelRespondHeader}>
{t("q:match.action.acceptCancelingSet")}
</div>
<div className={styles.cancelRespondButtons}>
<SendouButton
variant="outlined"
icon={<X />}
isDisabled={cancelFetcher.state !== "idle"}
onPress={() => {
cancelFetcher.submit(
{ _action: "REFUSE_CANCEL" },
{ method: "post" },
);
}}
>
{t("common:actions.refuse")}
</SendouButton>
<SendouButton
variant="outlined"
icon={<Check />}
isDisabled={cancelFetcher.state !== "idle"}
onPress={() => {
cancelFetcher.submit(
{ _action: "ACCEPT_CANCEL" },
{ method: "post" },
);
}}
>
{t("common:actions.accept")}
</SendouButton>
</div>
</div>
</SendouTabPanel>
);
}
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 (
<SendouTabPanel id={TAB_KEYS.ACTION}>
{isStaffOnly || !viewerGroup || !user ? (
showTimeline ? (
<MatchTimeline compact teams={teams} score={score} maps={maps} />
) : null
) : (
<div className={styles.rematchContent}>
{viewerGroup.matchmade ? (
<MatchmadeRejoinSection
data={data}
viewerGroup={viewerGroup}
viewerUserId={user.id}
awaitingConfirmation={awaitingConfirmation}
isOnReporterTeam={isOnReporterTeam}
/>
) : null}
{!viewerGroup.matchmade &&
(!awaitingConfirmation || isOnReporterTeam) ? (
<TrustedRejoinSection
viewerGroup={viewerGroup}
viewerUserId={user.id}
/>
) : null}
{isOnReporterTeam ? <hr className={styles.divider} /> : null}
{showTimeline ? (
<MatchTimeline compact teams={teams} score={score} maps={maps} />
) : null}
{isOnConfirmerTeam ? <ScoreConfirmerSection data={data} /> : null}
{isOnReporterTeam ? <ReporterUndoSection /> : null}
<WeaponReportSection data={data} viewerUserId={user.id} />
</div>
)}
</SendouTabPanel>
);
}
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 <WeaponReporter {...weaponReport} />;
}
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 (
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={confirmFetcherPending}
onPress={() => {
if (!decidingMap?.winnerGroupId) return;
fetcher.submit(
{
_action: "REPORT_SCORE",
winnerId: String(decidingMap.winnerGroupId),
reportedCount: String(reportedCount),
},
{ method: "post" },
);
}}
>
{t("q:match.confirmScore")}
</SendouButton>
<p className="text-lighter text-xs text-center">
{t("q:match.confirmScore.wrongHint")}
</p>
</div>
);
}
function ReporterUndoSection() {
const { t } = useTranslation(["q"]);
const undoFetcher = useFetcher();
return (
<div className="stack md items-center">
<p className="text-lighter text-sm">
{t("q:match.waitingForConfirmation")}
</p>
<SendouButton
variant="outlined"
size="small"
isPending={undoFetcher.state !== "idle"}
onPress={() => {
undoFetcher.submit(
{ _action: "UNDO_MATCH_REPORT" },
{ method: "post" },
);
}}
>
{t("q:match.undoReport")}
</SendouButton>
</div>
);
}
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 (
<MatchActionTab
key={reportedCount}
teams={[
{ id: data.match.groupAlpha.id, name: groupNames.alpha },
{ id: data.match.groupBravo.id, name: groupNames.bravo },
]}
ownTeamId={ownTeamId}
stageId={currentMap.stageId}
mode={currentMap.mode}
withPoints={false}
isSubmitting={fetcher.state !== "idle"}
setEnding={setEnding}
onSubmit={({ winnerId }) => {
fetcher.submit(
{
_action: "REPORT_SCORE",
winnerId: String(winnerId),
reportedCount: String(reportedCount),
},
{ method: "post" },
);
}}
weaponReport={isStaffOnly ? undefined : weaponReport}
actionButtons={
<>
{isStaffOnly ? (
<FormWithConfirm
fields={[["_action", "ADMIN_CANCEL"]]}
dialogHeading={t("q:match.adminCancel.confirm")}
submitButtonText={t("common:actions.confirm")}
fetcher={cancelFetcher}
>
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Ban size={16} />}
>
{t("q:match.action.adminCancel")}
</SendouButton>
</FormWithConfirm>
) : (
<FormWithConfirm
fields={[["_action", "REQUEST_CANCEL"]]}
dialogHeading={t("q:match.cancelMatch.confirm")}
submitButtonText={t("common:actions.confirm")}
fetcher={cancelFetcher}
>
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Ban size={16} />}
>
{t("q:match.action.requestCancel")}
</SendouButton>
</FormWithConfirm>
)}
{scoreIsNotZero ? (
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
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")}
</SendouButton>
) : 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,
},
};
}

View File

@ -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 = (
<MatchBannerBottomRow
games={data.match.mapList.map((map) => ({
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 (
<MatchBannerContainer>
<SendouQMatchBannerTopRow
data={data}
awaitingConfirmation={awaitingConfirmation}
/>
{data.match.isCanceled ? (
<IconBanner icon={<Ban size={32} />} header={t("q:match.canceled")} />
) : (
<MultiMatchBanner stageIds={playedStageIds} />
)}
{bottomRow}
</MatchBannerContainer>
);
}
const currentMap = data.match.currentMap;
invariant(currentMap);
return (
<MatchBannerContainer>
<SendouQMatchBannerTopRow data={data} awaitingConfirmation={false} />
{cancelRequesterName ? (
<IconBanner
icon={<Ban size={32} />}
header={t("q:match.cancelRequested")}
subtitle={t("q:match.cancelRequested.subtitle", {
teamName: cancelRequesterName,
})}
/>
) : (
<MatchBanner
stageId={currentMap.stageId}
mode={currentMap.mode}
screenLegal={
!data.match.groupAlpha.noScreen && !data.match.groupBravo.noScreen
}
>
<CurrentMapVotesBadge voters={currentMap.voters} />
</MatchBanner>
)}
{bottomRow}
</MatchBannerContainer>
);
}
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<number | null>(
(acc, m) =>
m.reportedAt && (!acc || m.reportedAt > acc) ? m.reportedAt : acc,
null,
);
const lastReportAt = lastMapReportedAt
? databaseTimestampToDate(lastMapReportedAt)
: startedAt;
return (
<MatchBannerTopRow
score={{
alpha: alphaWins,
bravo: bravoWins,
isFinal: Boolean(data.match.isLocked),
count: SENDOUQ_BEST_OF,
bestOf: true,
}}
time={
data.match.isLocked || awaitingConfirmation
? undefined
: {
currentMinutes: Math.max(
0,
differenceInMinutes(now, lastReportAt),
),
totalMinutes: Math.max(0, differenceInMinutes(now, startedAt)),
}
}
/>
);
}
function CurrentMapVotesBadge({
voters,
}: {
voters: NonNullable<SendouQMatchLoaderData["match"]["currentMap"]>["voters"];
}) {
const { t } = useTranslation(["q"]);
if (voters.length === 0) return null;
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={bannerStyles.infoBadge}>
{voters.length} <Vote />
</SendouButton>
}
>
<div className="stack sm">
<div className="text-sm text-lighter font-semi-bold">
{t("q:match.mapVoters.header")}
</div>
{voters.map((voter) => (
<div key={voter.id} className="stack sm horizontal items-center xs">
<Avatar user={voter} size="xxs" />
{voter.username}
</div>
))}
</div>
</SendouPopover>
);
}

View File

@ -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 (
<MatchPageHeader
subtitle={`SendouQ Season ${season}`}
topRight={
<LinkButton
to={SENDOUQ_RULES_PAGE}
variant="outlined"
size="small"
icon={<Scale />}
>
{t("q:front.nav.rules.title")}
</LinkButton>
}
>
{t("q:match.header", { number: data.match.id })}
</MatchPageHeader>
);
}

View File

@ -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 (
<>
<AddPrivateNoteDialog
aboutUser={addingNoteFor}
close={() => navigate(sendouQMatchPage(data.match.id))}
/>
<MatchTabs tabs={tabs}>
{isLocked || hasReportedMaps ? (
<MatchResultTab
teams={resolveTimelineTeams(data.match, t)}
score={{ alpha: alphaWins, bravo: bravoWins }}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
spChanges={resolveTimelineSpChanges(data.match)}
isOngoing={!isLocked && hasReportedMaps}
>
{data.match.cancelRequestedByUserId ? (
<p className="text-lighter text-xxs text-center mt-4">
{t("q:match.canceled.detail", {
requester: resolveCancelRequesterUsername(data.match),
accepter: resolveCancelAccepterUsername(data.match),
})}
</p>
) : null}
</MatchResultTab>
) : null}
{!isLocked && isParticipant ? (
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}
isConfirming={isConfirming}
pool={`SQ${String(data.match.id).at(-1)}`}
pass={resolveRoomPass(data.match.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
) : null}
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[false, false]}
teams={[
{
team: mapRosterTeam(data.match.groupAlpha.team),
defaultName: t("q:match.groupAlpha"),
members: mapRosterMembers(data.match.groupAlpha.members, {
viewerId: user?.id,
isOwnTeam: userSide === "ALPHA",
}),
tier: data.match.groupAlpha.tier ?? undefined,
},
{
team: mapRosterTeam(data.match.groupBravo.team),
defaultName: t("q:match.groupBravo"),
members: mapRosterMembers(data.match.groupBravo.members, {
viewerId: user?.id,
isOwnTeam: userSide === "BRAVO",
}),
tier: data.match.groupBravo.tier ?? undefined,
},
]}
/>
{showActionTab ? (
<SendouQMatchActionTab
data={data}
currentMap={currentMap ?? undefined}
ownTeamId={ownTeamId}
reportedCount={
data.match.mapList.filter((m) => m.winnerGroupId !== null).length
}
viewerSide={userSide}
/>
) : null}
</MatchTabs>
</>
);
}
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,
};
}

View File

@ -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<SQMatch, "groupAlpha" | "groupBravo">;
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]);
});
});

View File

@ -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<SQMatch, "groupAlpha" | "groupBravo">,
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);
}

View File

@ -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<number | null>) {
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);
});
});

View File

@ -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;
}

View File

@ -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<string, unknown> = {}) {
return {
id: 1,
username: "user",
discordId: "d",
discordAvatar: null,
customUrl: null,
skillDifference: undefined,
...overrides,
};
}
function matchWith(overrides: Record<string, unknown> = {}): 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 });
});
});

View File

@ -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,
},
};
}

View File

@ -303,7 +303,7 @@ export function compareMatchToReportedScores({
newReporterGroupId,
previousReporterGroupId,
}: {
match: Pick<SQMatch, "reportedByUserId" | "mapList"> & {
match: Pick<SQMatch, "mapList"> & {
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";

View File

@ -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;
}

View File

@ -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,

View File

@ -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<typeof ReportedWeaponRepository.findByMatchId>
>;
mapList: NonNullable<
Awaited<ReturnType<typeof SQMatchRepository.findById>>
>["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;
}

View File

@ -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,
};
}

View File

@ -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<typeof loader>;

View File

@ -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({

View File

@ -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;
}

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

@ -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",
});

View File

@ -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);
});
});

View File

@ -131,7 +131,7 @@ type CreateGroupArgs = {
status: Exclude<Tables["Group"]["status"], "INACTIVE">;
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<Tables["Group"]["status"], "INACTIVE">;
};
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<DB>) {
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<DB>) {
.where("id", "=", groupId)
.execute();
}
async function recordImplicitRejoinNoVote(
userId: number,
trx: Transaction<DB>,
): Promise<string | null> {
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;
}

View File

@ -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();

View File

@ -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();

View File

@ -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<SQGroupMember | SQMatchGroupMember> | undefined =
group.members;
return (
<GroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
<section className={styles.group} data-testid="sendouq-group-card">
{members ? (
{group.members ? (
<div className="stack md">
{members.map((member) => {
{group.members.map((member) => {
return (
<GroupMember
member={member}
@ -273,7 +267,7 @@ function GroupMember({
showAddNote,
showNote,
}: {
member: SQGroupMember | SQMatchGroupMember;
member: SQGroupMember;
showActions: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
@ -775,7 +769,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
function VoiceChatInfo({
member,
}: {
member: Pick<SQMatchGroupMember, "id" | "vc" | "languages">;
member: Pick<SQGroupMember, "id" | "vc" | "languages">;
}) {
const user = useUser();
const { t } = useTranslation(["q"]);

View File

@ -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();
};

View File

@ -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<ReturnType<SendouQClass["mapMatch"]>>;
export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
export type SQGroupMember = NonNullable<SQGroup["members"]>[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 {

Some files were not shown because too many files have changed in this diff Show More