mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-05 20:56:13 -05:00
New match page (#3032)
This commit is contained in:
parent
8b78522b74
commit
2b5b1b1948
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
& > label {
|
||||
margin: 0;
|
||||
text-box: trim-start cap alphabetic;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
197
app/components/match-page/MatchActionPickBanTab.module.css
Normal file
197
app/components/match-page/MatchActionPickBanTab.module.css
Normal 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);
|
||||
}
|
||||
380
app/components/match-page/MatchActionPickBanTab.tsx
Normal file
380
app/components/match-page/MatchActionPickBanTab.tsx
Normal 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;
|
||||
}
|
||||
273
app/components/match-page/MatchActionTab.module.css
Normal file
273
app/components/match-page/MatchActionTab.module.css
Normal 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);
|
||||
}
|
||||
334
app/components/match-page/MatchActionTab.tsx
Normal file
334
app/components/match-page/MatchActionTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
app/components/match-page/MatchBanner.module.css
Normal file
157
app/components/match-page/MatchBanner.module.css
Normal 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);
|
||||
}
|
||||
144
app/components/match-page/MatchBanner.tsx
Normal file
144
app/components/match-page/MatchBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
app/components/match-page/MatchBannerBottomRow.module.css
Normal file
56
app/components/match-page/MatchBannerBottomRow.module.css
Normal 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);
|
||||
}
|
||||
102
app/components/match-page/MatchBannerBottomRow.tsx
Normal file
102
app/components/match-page/MatchBannerBottomRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
app/components/match-page/MatchBannerTopRow.module.css
Normal file
15
app/components/match-page/MatchBannerTopRow.module.css
Normal 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);
|
||||
}
|
||||
88
app/components/match-page/MatchBannerTopRow.tsx
Normal file
88
app/components/match-page/MatchBannerTopRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
app/components/match-page/MatchJoinTab.module.css
Normal file
86
app/components/match-page/MatchJoinTab.module.css
Normal 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;
|
||||
}
|
||||
146
app/components/match-page/MatchJoinTab.tsx
Normal file
146
app/components/match-page/MatchJoinTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
app/components/match-page/MatchPage.module.css
Normal file
5
app/components/match-page/MatchPage.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
12
app/components/match-page/MatchPage.tsx
Normal file
12
app/components/match-page/MatchPage.tsx
Normal 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>;
|
||||
}
|
||||
14
app/components/match-page/MatchPageHeader.module.css
Normal file
14
app/components/match-page/MatchPageHeader.module.css
Normal 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);
|
||||
}
|
||||
21
app/components/match-page/MatchPageHeader.tsx
Normal file
21
app/components/match-page/MatchPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/components/match-page/MatchResultTab.tsx
Normal file
16
app/components/match-page/MatchResultTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
app/components/match-page/MatchRosterTab.module.css
Normal file
258
app/components/match-page/MatchRosterTab.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
530
app/components/match-page/MatchRosterTab.tsx
Normal file
530
app/components/match-page/MatchRosterTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
app/components/match-page/MatchTabs.module.css
Normal file
7
app/components/match-page/MatchTabs.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
76
app/components/match-page/MatchTabs.tsx
Normal file
76
app/components/match-page/MatchTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
app/components/match-page/MatchTimeline.module.css
Normal file
289
app/components/match-page/MatchTimeline.module.css
Normal 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);
|
||||
}
|
||||
632
app/components/match-page/MatchTimeline.tsx
Normal file
632
app/components/match-page/MatchTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
app/components/match-page/WeaponPool.module.css
Normal file
27
app/components/match-page/WeaponPool.module.css
Normal 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);
|
||||
}
|
||||
54
app/components/match-page/WeaponPool.tsx
Normal file
54
app/components/match-page/WeaponPool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
app/components/match-page/WeaponReporter.module.css
Normal file
108
app/components/match-page/WeaponReporter.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
170
app/components/match-page/WeaponReporter.tsx
Normal file
170
app/components/match-page/WeaponReporter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
app/components/match-page/useMatchWeaponReport.ts
Normal file
73
app/components/match-page/useMatchWeaponReport.ts
Normal 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" },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
141
app/components/match-page/utils.test.ts
Normal file
141
app/components/match-page/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
app/components/match-page/utils.ts
Normal file
83
app/components/match-page/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export const SEED_VARIATIONS = [
|
|||
"TEAM_MAP_PREFS",
|
||||
"FINALIZED_BRACKET",
|
||||
"AB_RR",
|
||||
"IN_SQ_MATCH",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ function ChatProviderInner({
|
|||
"system:",
|
||||
isSystemMessage,
|
||||
);
|
||||
if (isSystemMessage) {
|
||||
if (isSystemMessage || messageArr[0].revalidateOnly) {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
|
|
|
|||
58
app/features/chat/RoomLinkRepository.server.ts
Normal file
58
app/features/chat/RoomLinkRepository.server.ts
Normal 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();
|
||||
}
|
||||
117
app/features/chat/chat-constants.test.ts
Normal file
117
app/features/chat/chat-constants.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type SystemMessageType =
|
|||
| "SCORE_CONFIRMED"
|
||||
| "CANCEL_REPORTED"
|
||||
| "CANCEL_CONFIRMED"
|
||||
| "CANCEL_REFUSED"
|
||||
| "TOURNAMENT_UPDATED"
|
||||
| "TOURNAMENT_MATCH_UPDATED";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
81
app/features/chat/room-link-utils.ts
Normal file
81
app/features/chat/room-link-utils.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
37
app/features/chat/routes/room.ts
Normal file
37
app/features/chat/routes/room.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
732
app/features/match-page-test/routes/match-page-test.tsx
Normal file
732
app/features/match-page-test/routes/match-page-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
"=",
|
||||
|
|
|
|||
86
app/features/scrims/components/ScrimMatchBanner.tsx
Normal file
86
app/features/scrims/components/ScrimMatchBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
app/features/scrims/components/ScrimMatchHeader.tsx
Normal file
70
app/features/scrims/components/ScrimMatchHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
app/features/scrims/components/ScrimMatchTabs.tsx
Normal file
76
app/features/scrims/components/ScrimMatchTabs.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface ScrimPostRequest {
|
|||
|
||||
export interface ScrimPostUser extends CommonUser {
|
||||
isOwner: boolean;
|
||||
inGameName: string | null;
|
||||
}
|
||||
|
||||
interface ScrimPostTeam {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
108
app/features/sendouq-match/components/RejoinSections.tsx
Normal file
108
app/features/sendouq-match/components/RejoinSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
131
app/features/sendouq-match/components/RematchVotePanel.tsx
Normal file
131
app/features/sendouq-match/components/RematchVotePanel.tsx
Normal 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" />
|
||||
);
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
524
app/features/sendouq-match/components/SendouQMatchActionTab.tsx
Normal file
524
app/features/sendouq-match/components/SendouQMatchActionTab.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
188
app/features/sendouq-match/components/SendouQMatchBanner.tsx
Normal file
188
app/features/sendouq-match/components/SendouQMatchBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
app/features/sendouq-match/components/SendouQMatchHeader.tsx
Normal file
34
app/features/sendouq-match/components/SendouQMatchHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
app/features/sendouq-match/components/SendouQMatchTabs.tsx
Normal file
238
app/features/sendouq-match/components/SendouQMatchTabs.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
116
app/features/sendouq-match/core/RejoinVote.test.ts
Normal file
116
app/features/sendouq-match/core/RejoinVote.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
96
app/features/sendouq-match/core/RejoinVote.ts
Normal file
96
app/features/sendouq-match/core/RejoinVote.ts
Normal 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);
|
||||
}
|
||||
44
app/features/sendouq-match/core/SendouQMatch.test.ts
Normal file
44
app/features/sendouq-match/core/SendouQMatch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
app/features/sendouq-match/core/SendouQMatch.ts
Normal file
49
app/features/sendouq-match/core/SendouQMatch.ts
Normal 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;
|
||||
}
|
||||
228
app/features/sendouq-match/core/match-timeline.test.ts
Normal file
228
app/features/sendouq-match/core/match-timeline.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
124
app/features/sendouq-match/core/match-timeline.ts
Normal file
124
app/features/sendouq-match/core/match-timeline.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
168
app/features/sendouq/SQGroupRepository.server.test.ts
Normal file
168
app/features/sendouq/SQGroupRepository.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user