mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-04 11:22:40 -05:00
Scrim map by map mode (#3104)
This commit is contained in:
parent
72a3d81640
commit
a3d7cb2fcb
|
|
@ -5,7 +5,8 @@
|
|||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: var(--font-xs);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
.label {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
margin-block-end: var(--label-margin);
|
||||
margin: 0;
|
||||
display: block;
|
||||
text-box: trim-start cap alphabetic;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,13 +146,17 @@
|
|||
.select {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
margin-block-end: var(--label-margin);
|
||||
margin: 0;
|
||||
display: block;
|
||||
text-box: trim-start cap alphabetic;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export const TournamentSearch = React.forwardRef(function TournamentSearch<
|
|||
placeholder=""
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={onSelectionChange as (key: Key | null) => void}
|
||||
className={selectStyles.select}
|
||||
aria-label="Tournament search"
|
||||
{...rest}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import { Check, X } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
|
|
@ -11,7 +12,6 @@ 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;
|
||||
|
|
@ -30,7 +30,7 @@ interface MatchActionPickBanTabProps {
|
|||
type: "PICK" | "BAN";
|
||||
onSubmit?: (data: PickBanSubmission) => void;
|
||||
isSubmitting?: boolean;
|
||||
weaponReport?: WeaponReporterProps;
|
||||
secondaryAction?: React.ReactNode;
|
||||
waitingFor?: string;
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ export function MatchActionPickBanTab({
|
|||
type,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
weaponReport,
|
||||
secondaryAction,
|
||||
waitingFor,
|
||||
}: MatchActionPickBanTabProps) {
|
||||
const { t } = useTranslation(["q", "common", "game-misc"]);
|
||||
|
|
@ -155,7 +155,7 @@ export function MatchActionPickBanTab({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
|
||||
{secondaryAction}
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
type MatchTimelineProps,
|
||||
type TimelineMap,
|
||||
} from "./MatchTimeline";
|
||||
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
|
||||
|
||||
const LONG_TEAM_NAME_THRESHOLD = 16;
|
||||
|
||||
|
|
@ -30,6 +29,7 @@ interface ActionTabTeam {
|
|||
}
|
||||
|
||||
interface SetEndingData extends MatchTimelineProps {
|
||||
score: { alpha: number; bravo: number };
|
||||
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
|
||||
setEndingTeamIds: number[];
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ interface MatchActionTabProps {
|
|||
isSubmitting?: boolean;
|
||||
setEnding?: SetEndingData;
|
||||
actionButtons?: React.ReactNode;
|
||||
weaponReport?: WeaponReporterProps;
|
||||
secondaryAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MatchActionTab({
|
||||
|
|
@ -57,7 +57,7 @@ export function MatchActionTab({
|
|||
isSubmitting,
|
||||
setEnding,
|
||||
actionButtons,
|
||||
weaponReport,
|
||||
secondaryAction,
|
||||
}: MatchActionTabProps) {
|
||||
const { t } = useTranslation(["q", "game-misc", "common"]);
|
||||
const [winnerId, setWinnerId] = useState<number | null>(null);
|
||||
|
|
@ -188,7 +188,7 @@ export function MatchActionTab({
|
|||
</SendouButton>
|
||||
</div>
|
||||
)}
|
||||
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
|
||||
{secondaryAction}
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface MatchBannerProps {
|
|||
screenLegal?: boolean;
|
||||
joinPool?: string | null;
|
||||
joinViaQr?: boolean;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MatchBanner({
|
||||
|
|
|
|||
24
app/components/match-page/MatchBannerScheduledTime.tsx
Normal file
24
app/components/match-page/MatchBannerScheduledTime.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import TimePopover from "~/components/TimePopover";
|
||||
|
||||
interface MatchBannerScheduledTimeProps {
|
||||
time: Date;
|
||||
}
|
||||
|
||||
export function MatchBannerScheduledTime({
|
||||
time,
|
||||
}: MatchBannerScheduledTimeProps) {
|
||||
return (
|
||||
<TimePopover
|
||||
time={time}
|
||||
options={{
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
className="font-semi-bold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
app/components/match-page/MatchBannerStartedAt.tsx
Normal file
22
app/components/match-page/MatchBannerStartedAt.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
|
||||
interface MatchBannerStartedAtProps {
|
||||
time: Date;
|
||||
}
|
||||
|
||||
export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) {
|
||||
return (
|
||||
<LocaleTime
|
||||
date={time}
|
||||
options={{
|
||||
month: "numeric",
|
||||
year: "2-digit",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
className="text-lighter font-semi-bold"
|
||||
inline
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
app/components/match-page/MatchBannerTimer.tsx
Normal file
47
app/components/match-page/MatchBannerTimer.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import styles from "./MatchBannerTopRow.module.css";
|
||||
|
||||
const MAX_MINUTES = 60;
|
||||
|
||||
interface MatchBannerTimerProps {
|
||||
time: {
|
||||
currentMinutes: number;
|
||||
totalMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function MatchBannerTimer({ time }: MatchBannerTimerProps) {
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1,31 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import styles from "./MatchBannerTopRow.module.css";
|
||||
|
||||
interface MatchBannerTopRowProps {
|
||||
score: {
|
||||
score?: {
|
||||
alpha: number;
|
||||
bravo: number;
|
||||
isFinal: boolean;
|
||||
count: number;
|
||||
bestOf: boolean;
|
||||
};
|
||||
time?: {
|
||||
currentMinutes: number;
|
||||
totalMinutes: number;
|
||||
count?: number;
|
||||
bestOf?: boolean;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MatchBannerTopRow({ score, time }: MatchBannerTopRowProps) {
|
||||
export function MatchBannerTopRow({ score, children }: MatchBannerTopRowProps) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Score score={score} />
|
||||
{time ? <Timer time={time} /> : null}
|
||||
{score ? <Score score={score} /> : <div />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
|
||||
function Score({
|
||||
score,
|
||||
}: {
|
||||
score: NonNullable<MatchBannerTopRowProps["score"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
|
|
@ -39,50 +39,12 @@ function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
|
|||
>
|
||||
{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 })}
|
||||
: score.count !== undefined
|
||||
? score.bestOf
|
||||
? t("q:match.banner.bestOf", { count: score.count })
|
||||
: t("q:match.banner.playAll", { count: score.count })
|
||||
: null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
DoorOpen,
|
||||
Key,
|
||||
ScrollText,
|
||||
Tally5,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
|
@ -19,6 +26,7 @@ export const TAB_KEYS = {
|
|||
ACTION: "action",
|
||||
JOIN: "join",
|
||||
RESULT: "result",
|
||||
STATS: "stats",
|
||||
ADMIN: "admin",
|
||||
} as const;
|
||||
|
||||
|
|
@ -27,6 +35,7 @@ const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
|
|||
action: <Tally5 />,
|
||||
join: <DoorOpen />,
|
||||
result: <ScrollText />,
|
||||
stats: <BarChart3 />,
|
||||
admin: <Key />,
|
||||
};
|
||||
|
||||
|
|
@ -35,6 +44,7 @@ const TAB_TRANSLATION_KEYS = {
|
|||
action: "q:match.tabs.action",
|
||||
join: "common:actions.join",
|
||||
result: "q:match.tabs.result",
|
||||
stats: "q:match.tabs.stats",
|
||||
admin: "common:pages.admin",
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export interface TimelinePickBanEvent {
|
|||
|
||||
export interface MatchTimelineProps {
|
||||
teams: { alpha: TimelineTeam; bravo: TimelineTeam };
|
||||
score: { alpha: number; bravo: number };
|
||||
score?: { alpha: number; bravo: number };
|
||||
maps: TimelineMap[];
|
||||
spChanges?: TimelineSpChanges;
|
||||
/** When true, render only the team + score header (no per-map rows or SP section). */
|
||||
|
|
@ -175,9 +175,11 @@ function TimelineHeader({
|
|||
) : null}
|
||||
</div>
|
||||
<div className={styles.headerScore}>
|
||||
<span className={styles.headerScoreValue}>
|
||||
{score.alpha}-{score.bravo}
|
||||
</span>
|
||||
{score ? (
|
||||
<span className={styles.headerScoreValue}>
|
||||
{score.alpha}-{score.bravo}
|
||||
</span>
|
||||
) : null}
|
||||
{isOngoing ? (
|
||||
<span className={styles.headerScoreLive}>
|
||||
{t("q:match.timeline.live")}
|
||||
|
|
|
|||
38
app/components/match-page/SecondaryAction.module.css
Normal file
38
app/components/match-page/SecondaryAction.module.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.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));
|
||||
}
|
||||
|
||||
.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;
|
||||
position: relative;
|
||||
|
||||
&.standalone {
|
||||
margin-block-start: calc(-1 * var(--s-6));
|
||||
min-height: 200px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.collapseButton {
|
||||
position: absolute;
|
||||
inset-block-start: var(--s-2);
|
||||
inset-inline-end: var(--s-3);
|
||||
|
||||
& svg {
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
}
|
||||
}
|
||||
62
app/components/match-page/SecondaryAction.tsx
Normal file
62
app/components/match-page/SecondaryAction.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import clsx from "clsx";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
import styles from "./SecondaryAction.module.css";
|
||||
|
||||
interface SecondaryActionProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
collapsedLabel: string;
|
||||
collapsedIcon?: JSX.Element;
|
||||
expandedAriaLabel?: string;
|
||||
standalone?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic collapsible panel rendered below the primary match action.
|
||||
* Hosts optional follow-up actions (e.g. weapon reporting, scrim map list
|
||||
* management) and switches to a full-tab standalone variant when there is
|
||||
* no primary action to sit underneath.
|
||||
*/
|
||||
export function SecondaryAction({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
collapsedLabel,
|
||||
collapsedIcon,
|
||||
expandedAriaLabel,
|
||||
standalone,
|
||||
children,
|
||||
}: SecondaryActionProps) {
|
||||
if (!isOpen && !standalone) {
|
||||
return (
|
||||
<div className={styles.rootCollapsed}>
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
icon={collapsedIcon}
|
||||
onPress={() => onOpenChange(true)}
|
||||
>
|
||||
{collapsedLabel}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.root, { [styles.standalone]: standalone })}>
|
||||
{standalone ? null : (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={<ChevronUp size={22} />}
|
||||
onPress={() => onOpenChange(false)}
|
||||
className={styles.collapseButton}
|
||||
aria-label={expandedAriaLabel ?? collapsedLabel}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,3 @@
|
|||
.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;
|
||||
|
|
@ -76,33 +64,3 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import clsx from "clsx";
|
||||
import { ChevronUp, Crosshair } from "lucide-react";
|
||||
import { Crosshair } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
|
|
@ -13,6 +12,7 @@ import { abilityImageUrl, SETTINGS_PAGE } from "~/utils/urls";
|
|||
import { SendouButton } from "../elements/Button";
|
||||
import { Image, StageImage, WeaponImage } from "../Image";
|
||||
import { WeaponSelect } from "../WeaponSelect";
|
||||
import { SecondaryAction } from "./SecondaryAction";
|
||||
import styles from "./WeaponReporter.module.css";
|
||||
|
||||
interface WeaponReporterMap {
|
||||
|
|
@ -64,37 +64,14 @@ export function WeaponReporter({
|
|||
);
|
||||
};
|
||||
|
||||
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,
|
||||
})}
|
||||
<SecondaryAction
|
||||
isOpen={isOpen}
|
||||
onOpenChange={handleToggle}
|
||||
collapsedLabel={t("q:match.actions.reportWeapons")}
|
||||
collapsedIcon={<Crosshair size={16} />}
|
||||
standalone={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} />
|
||||
|
|
@ -153,7 +130,7 @@ export function WeaponReporter({
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SecondaryAction>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3188,7 +3188,34 @@ async function scrimPosts() {
|
|||
return result;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
// Deterministic post 1: admin (Sendou) vs N-ZAP. The e2e map-by-map test
|
||||
// navigates straight to /scrims/1 and relies on this being an accepted
|
||||
// scrim with admin on the ALPHA side and N-ZAP on the BRAVO side.
|
||||
const adminVsNzapAt = date(true);
|
||||
const adminVsNzapPostId = await ScrimPostRepository.insert({
|
||||
at: adminVsNzapAt,
|
||||
rangeEnd: null,
|
||||
isScheduledForFuture: true,
|
||||
teamId: null,
|
||||
text: null,
|
||||
visibility: null,
|
||||
users: users()
|
||||
.map((u) => ({ ...u, isOwner: 0 }))
|
||||
.concat({ userId: ADMIN_ID, isOwner: 1 }),
|
||||
managedByAnyone: true,
|
||||
maps: null,
|
||||
mapsTournamentId: 4,
|
||||
});
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: adminVsNzapPostId,
|
||||
users: users()
|
||||
.map((u) => ({ ...u, isOwner: 0 }))
|
||||
.concat({ userId: NZAP_TEST_ID, isOwner: 1 }),
|
||||
message: null,
|
||||
});
|
||||
await ScrimPostRepository.acceptRequest(1);
|
||||
|
||||
for (let i = 0; i < 19; i++) {
|
||||
const divs = divRange();
|
||||
const atTime = date();
|
||||
const hasRangeEnd = Math.random() > 0.5;
|
||||
|
|
@ -3258,7 +3285,9 @@ async function scrimPostRequests() {
|
|||
.where("TeamMember.teamId", "=", 1)
|
||||
.execute();
|
||||
|
||||
for (const id of [1, 5, 12, 14, 19]) {
|
||||
// Post 1 is already accepted (admin-vs-nzap, seeded in scrimPosts()), so it
|
||||
// is excluded here.
|
||||
for (const id of [5, 12, 14, 19]) {
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: id,
|
||||
users: allianceRogueMembers.map((member) => ({
|
||||
|
|
@ -3272,8 +3301,6 @@ async function scrimPostRequests() {
|
|||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
await ScrimPostRepository.acceptRequest(3);
|
||||
}
|
||||
|
||||
async function associations() {
|
||||
|
|
|
|||
|
|
@ -1259,6 +1259,27 @@ export interface ScrimPost {
|
|||
updatedAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface ScrimMapList {
|
||||
id: GeneratedAlways<number>;
|
||||
scrimPostId: number;
|
||||
side: "ALPHA" | "BRAVO";
|
||||
source: "TOURNAMENT" | "POOL";
|
||||
tournamentId: number | null;
|
||||
serializedPool: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ScrimMap {
|
||||
id: GeneratedAlways<number>;
|
||||
scrimPostId: number;
|
||||
index: number;
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
winnerSide: "ALPHA" | "BRAVO" | null;
|
||||
reportedAt: number | null;
|
||||
reportedByUserId: number | null;
|
||||
}
|
||||
|
||||
export interface ScrimPostUser {
|
||||
scrimPostId: number;
|
||||
userId: number;
|
||||
|
|
@ -1442,6 +1463,8 @@ export interface DB {
|
|||
ScrimPostUser: ScrimPostUser;
|
||||
ScrimPostRequest: ScrimPostRequest;
|
||||
ScrimPostRequestUser: ScrimPostRequestUser;
|
||||
ScrimMapList: ScrimMapList;
|
||||
ScrimMap: ScrimMap;
|
||||
Association: Association;
|
||||
AssociationMember: AssociationMember;
|
||||
Notification: Notification;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ export type SystemMessageType =
|
|||
| "CANCEL_CONFIRMED"
|
||||
| "CANCEL_REFUSED"
|
||||
| "TOURNAMENT_UPDATED"
|
||||
| "TOURNAMENT_MATCH_UPDATED";
|
||||
| "TOURNAMENT_MATCH_UPDATED"
|
||||
| "MAP_REPLAYED"
|
||||
| "MAP_PICKED";
|
||||
|
||||
export type SystemMessageContext = {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@ export function Chat({
|
|||
case "USER_LEFT": {
|
||||
return t("common:chat.systemMsg.userLeft", { name: name() });
|
||||
}
|
||||
case "MAP_REPLAYED": {
|
||||
return t("common:chat.systemMsg.mapReplayed", { name: name() });
|
||||
}
|
||||
case "MAP_PICKED": {
|
||||
return t("common:chat.systemMsg.mapPicked", { name: name() });
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -599,3 +599,129 @@ describe("MapList.generate() with initialWeights", () => {
|
|||
expect(maps[0].stageId).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapList.resume()", () => {
|
||||
const POOL = new MapPool({
|
||||
TW: [],
|
||||
SZ: [1, 2, 3],
|
||||
TC: [4, 5, 6],
|
||||
RM: [7, 8, 9],
|
||||
CB: [10, 11, 12],
|
||||
});
|
||||
|
||||
function nextMap(
|
||||
history: Array<{ mode: "SZ" | "TC" | "RM" | "CB"; stageId: StageId }>,
|
||||
) {
|
||||
const gen = MapList.resume({ mapPool: POOL, history });
|
||||
gen.next();
|
||||
const result = gen.next({ amount: 1 }).value;
|
||||
return result![0];
|
||||
}
|
||||
|
||||
it("starts with the pool's first mode when history is empty", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(nextMap([]).mode).toBe("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates through modes in pool order across history length", () => {
|
||||
expect(nextMap([{ mode: "SZ", stageId: 1 }]).mode).toBe("TC");
|
||||
expect(
|
||||
nextMap([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
]).mode,
|
||||
).toBe("RM");
|
||||
expect(
|
||||
nextMap([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
]).mode,
|
||||
).toBe("CB");
|
||||
});
|
||||
|
||||
it("wraps the mode order back to the start after a full rotation", () => {
|
||||
const history = [
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "CB", stageId: 10 },
|
||||
] as const;
|
||||
expect(nextMap([...history]).mode).toBe("SZ");
|
||||
});
|
||||
|
||||
it("avoids already-played (mode, stage) pairs", () => {
|
||||
const history = [
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "CB", stageId: 10 },
|
||||
] as const;
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const next = nextMap([...history]);
|
||||
expect(next.mode).toBe("SZ");
|
||||
expect(next.stageId).not.toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates only through modes present in the pool", () => {
|
||||
const threeModePool = new MapPool({
|
||||
TW: [],
|
||||
SZ: [1, 2, 3],
|
||||
TC: [4, 5, 6],
|
||||
RM: [7, 8, 9],
|
||||
CB: [],
|
||||
});
|
||||
|
||||
const pickMode = (
|
||||
history: Array<{ mode: "SZ" | "TC" | "RM"; stageId: StageId }>,
|
||||
) => {
|
||||
const gen = MapList.resume({ mapPool: threeModePool, history });
|
||||
gen.next();
|
||||
return gen.next({ amount: 1 }).value![0].mode;
|
||||
};
|
||||
|
||||
expect(pickMode([])).toBe("SZ");
|
||||
expect(pickMode([{ mode: "SZ", stageId: 1 }])).toBe("TC");
|
||||
expect(
|
||||
pickMode([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
]),
|
||||
).toBe("RM");
|
||||
expect(
|
||||
pickMode([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
]),
|
||||
).toBe("SZ");
|
||||
});
|
||||
|
||||
it("exclusion is keyed on (mode, stage), not stage alone", () => {
|
||||
const sharedPool = new MapPool({
|
||||
TW: [],
|
||||
SZ: [1, 2],
|
||||
TC: [1, 2],
|
||||
RM: [],
|
||||
CB: [],
|
||||
});
|
||||
|
||||
const seenForTC = new Set<StageId>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const gen = MapList.resume({
|
||||
mapPool: sharedPool,
|
||||
history: [{ mode: "SZ", stageId: 1 }],
|
||||
});
|
||||
gen.next();
|
||||
const next = gen.next({ amount: 1 }).value![0];
|
||||
expect(next.mode).toBe("TC");
|
||||
seenForTC.add(next.stageId);
|
||||
}
|
||||
|
||||
expect(seenForTC.has(1)).toBe(true);
|
||||
expect(seenForTC.has(2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export function* generate(args: {
|
|||
initialWeights?: Map<string, number>;
|
||||
/** Skip the ensureMinimumCandidates check that inflates weights to ensure half the pool is available. Useful when initial weights already define the desired selection. */
|
||||
skipEnsureMinimumCandidates?: boolean;
|
||||
/** Fixed mode order — when set, skips the random `modeOrders` shuffle and uses only this order. Intended for `resume`. */
|
||||
modeOrder?: ModeShort[];
|
||||
}): Generator<Array<ModeWithStage>, Array<ModeWithStage>, GenerateNext> {
|
||||
if (args.mapPool.isEmpty()) {
|
||||
while (true) yield [];
|
||||
|
|
@ -64,7 +66,7 @@ export function* generate(args: {
|
|||
args.mapPool.parsed,
|
||||
args.initialWeights,
|
||||
);
|
||||
const orderedModes = modeOrders(modes);
|
||||
const orderedModes = args.modeOrder ? [args.modeOrder] : modeOrders(modes);
|
||||
let currentOrderIndex = 0;
|
||||
|
||||
const firstArgs = yield [];
|
||||
|
|
@ -135,6 +137,44 @@ export function* generate(args: {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a generator primed to continue map selection after the given history.
|
||||
*
|
||||
* Keeps the pool's mode order stable (rotated so the next-to-play mode is first)
|
||||
* and biases against already-played `(mode, stage)` pairs so they are not picked
|
||||
* again unless every option in that mode has already been played.
|
||||
*
|
||||
* @example
|
||||
* const generator = resume({ mapPool, history });
|
||||
* generator.next();
|
||||
* const { mode, stageId } = generator.next({ amount: 1 }).value![0];
|
||||
*/
|
||||
export function resume(args: {
|
||||
mapPool: MapPool;
|
||||
history: Array<{ mode: ModeShort; stageId: StageId }>;
|
||||
}) {
|
||||
const modes = args.mapPool.modes;
|
||||
const lastMode = args.history.at(-1)?.mode;
|
||||
const lastIdx = lastMode ? modes.indexOf(lastMode) : -1;
|
||||
const offset = modes.length > 0 ? (lastIdx + 1) % modes.length : 0;
|
||||
const modeOrder = [...modes.slice(offset), ...modes.slice(0, offset)];
|
||||
|
||||
const initialWeights = new Map<string, number>();
|
||||
for (const pair of args.mapPool.stageModePairs) {
|
||||
initialWeights.set(modeStageKey(pair.mode, pair.stageId), 0);
|
||||
}
|
||||
for (const { mode, stageId } of args.history) {
|
||||
initialWeights.set(modeStageKey(mode, stageId), -1000);
|
||||
}
|
||||
|
||||
return generate({
|
||||
mapPool: args.mapPool,
|
||||
modeOrder,
|
||||
initialWeights: initialWeights.size > 0 ? initialWeights : undefined,
|
||||
skipEnsureMinimumCandidates: true,
|
||||
});
|
||||
}
|
||||
|
||||
function initializeWeights(
|
||||
modes: ModeShort[],
|
||||
mapPool: ReadonlyMapPoolObject,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
MatchBannerContainer,
|
||||
} from "~/components/match-page/MatchBanner";
|
||||
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
|
||||
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
|
||||
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
|
||||
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
|
||||
import { MatchPage } from "~/components/match-page/MatchPage";
|
||||
|
|
@ -80,11 +81,14 @@ export default function MatchPageTestRoute() {
|
|||
count: 5,
|
||||
bestOf: true,
|
||||
}}
|
||||
time={{
|
||||
currentMinutes: 3,
|
||||
totalMinutes: 1,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<MatchBannerTimer
|
||||
time={{
|
||||
currentMinutes: 3,
|
||||
totalMinutes: 1,
|
||||
}}
|
||||
/>
|
||||
</MatchBannerTopRow>
|
||||
<IconBanner
|
||||
icon={<Ban size={32} />}
|
||||
header={t("q:match.cancelRequested")}
|
||||
|
|
|
|||
123
app/features/scrims/ScrimMapListRepository.server.ts
Normal file
123
app/features/scrims/ScrimMapListRepository.server.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, TablesInsertable } from "~/db/tables";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import * as ScrimMapRepository from "./ScrimMapRepository.server";
|
||||
import type { ScrimSide } from "./scrims-types";
|
||||
|
||||
type SubmitMapListArgs = Omit<TablesInsertable["ScrimMapList"], "updatedAt">;
|
||||
|
||||
/**
|
||||
* Inserts a map list row for the given side, replacing any existing row for
|
||||
* the same `(scrimPostId, side)` pair, and (atomically) generates and inserts
|
||||
* the next map for the scrim if no unreported map is currently waiting.
|
||||
*/
|
||||
export async function submitMapListAndGenerateIfNeeded(
|
||||
args: SubmitMapListArgs,
|
||||
): Promise<void> {
|
||||
const now = databaseTimestampNow();
|
||||
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.insertInto("ScrimMapList")
|
||||
.values({
|
||||
scrimPostId: args.scrimPostId,
|
||||
side: args.side,
|
||||
source: args.source,
|
||||
tournamentId: args.tournamentId ?? null,
|
||||
serializedPool: args.serializedPool ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["scrimPostId", "side"]).doUpdateSet({
|
||||
source: args.source,
|
||||
tournamentId: args.tournamentId ?? null,
|
||||
serializedPool: args.serializedPool ?? null,
|
||||
updatedAt: now,
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await ScrimMapRepository.tryGenerateAndInsertNextMapInTrx(
|
||||
trx,
|
||||
args.scrimPostId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a side's map list, if one exists. */
|
||||
export async function deleteMapList(
|
||||
scrimPostId: number,
|
||||
side: ScrimSide,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.deleteFrom("ScrimMapList")
|
||||
.where("scrimPostId", "=", scrimPostId)
|
||||
.where("side", "=", side)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export type ResolvedScrimMapList = {
|
||||
side: ScrimSide;
|
||||
mapList: Array<{ mode: ModeShort; stageId: StageId }>;
|
||||
tournament?: { id: number; name: string };
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns all submitted map lists for the scrim with the pool resolved into
|
||||
* concrete `(mode, stageId)` pairs. Tournament-sourced rows additionally carry
|
||||
* the tournament's id and name for display. Pass a transaction as `executor`
|
||||
* to read within an existing transaction.
|
||||
*/
|
||||
export async function findMapListsByScrimPostId(
|
||||
scrimPostId: number,
|
||||
executor: typeof db | Transaction<DB> = db,
|
||||
): Promise<ResolvedScrimMapList[]> {
|
||||
const rows = await executor
|
||||
.selectFrom("ScrimMapList")
|
||||
.leftJoin(
|
||||
"CalendarEvent",
|
||||
"ScrimMapList.tournamentId",
|
||||
"CalendarEvent.tournamentId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"ScrimMapList.side",
|
||||
"ScrimMapList.source",
|
||||
"ScrimMapList.tournamentId",
|
||||
"ScrimMapList.serializedPool",
|
||||
"ScrimMapList.updatedAt",
|
||||
eb.ref("CalendarEvent.name").as("tournamentName"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("MapPoolMap")
|
||||
.select(["MapPoolMap.mode", "MapPoolMap.stageId"])
|
||||
.whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"),
|
||||
).as("tournamentMapPool"),
|
||||
])
|
||||
.where("ScrimMapList.scrimPostId", "=", scrimPostId)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
side: row.side,
|
||||
mapList: resolveMapList(row),
|
||||
tournament:
|
||||
row.source === "TOURNAMENT" && row.tournamentId !== null
|
||||
? { id: row.tournamentId, name: row.tournamentName ?? "" }
|
||||
: undefined,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveMapList(row: {
|
||||
source: "TOURNAMENT" | "POOL";
|
||||
serializedPool: string | null;
|
||||
tournamentMapPool: Array<{ mode: ModeShort; stageId: StageId }>;
|
||||
}): Array<{ mode: ModeShort; stageId: StageId }> {
|
||||
if (row.source === "TOURNAMENT") return row.tournamentMapPool;
|
||||
if (!row.serializedPool) return [];
|
||||
return new MapPool(row.serializedPool).stageModePairs;
|
||||
}
|
||||
156
app/features/scrims/ScrimMapRepository.server.ts
Normal file
156
app/features/scrims/ScrimMapRepository.server.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import type { Transaction } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, TablesInsertable } from "~/db/tables";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import * as Scrim from "./core/Scrim";
|
||||
import * as ScrimMapByMap from "./core/ScrimMapByMap";
|
||||
import * as ScrimMapListRepository from "./ScrimMapListRepository.server";
|
||||
|
||||
interface ReportMapArgs {
|
||||
scrimPostId: number;
|
||||
mapId: number;
|
||||
winnerSide: NonNullable<TablesInsertable["ScrimMap"]["winnerSide"]>;
|
||||
reportedByUserId: NonNullable<
|
||||
TablesInsertable["ScrimMap"]["reportedByUserId"]
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an existing map as reported with the given winner side, and
|
||||
* (atomically) generates and inserts the next map for the scrim if no
|
||||
* unreported map is currently waiting.
|
||||
*/
|
||||
export async function reportMapAndGenerateNext(
|
||||
args: ReportMapArgs,
|
||||
): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.updateTable("ScrimMap")
|
||||
.set({
|
||||
winnerSide: args.winnerSide,
|
||||
reportedAt: databaseTimestampNow(),
|
||||
reportedByUserId: args.reportedByUserId,
|
||||
})
|
||||
.where("id", "=", args.mapId)
|
||||
.where("reportedAt", "is", null)
|
||||
.execute();
|
||||
|
||||
await tryGenerateAndInsertNextMapInTrx(trx, args.scrimPostId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses the most recent report: deletes the currently unreported map (the
|
||||
* auto-generated next slot, if any) and clears the winner/reportedAt fields on
|
||||
* the most recently reported map so it can be played again.
|
||||
*/
|
||||
export async function undoMostRecentMap(scrimPostId: number): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.deleteFrom("ScrimMap")
|
||||
.where("scrimPostId", "=", scrimPostId)
|
||||
.where("reportedAt", "is", null)
|
||||
.execute();
|
||||
|
||||
const latestReported = await trx
|
||||
.selectFrom("ScrimMap")
|
||||
.select("id")
|
||||
.where("scrimPostId", "=", scrimPostId)
|
||||
.where("reportedAt", "is not", null)
|
||||
.orderBy("index", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!latestReported) return;
|
||||
|
||||
await trx
|
||||
.updateTable("ScrimMap")
|
||||
.set({
|
||||
reportedAt: null,
|
||||
winnerSide: null,
|
||||
reportedByUserId: null,
|
||||
})
|
||||
.where("id", "=", latestReported.id)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
interface ReplaceCurrentMapArgs {
|
||||
scrimPostId: number;
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the currently unreported map for the scrim with the given
|
||||
* mode/stage. Used by both the "replay previous map" and "pick a map" actions.
|
||||
* The current map's index is preserved.
|
||||
*/
|
||||
export async function replaceCurrentMap(
|
||||
args: ReplaceCurrentMapArgs,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.updateTable("ScrimMap")
|
||||
.set({
|
||||
mode: args.mode,
|
||||
stageId: args.stageId,
|
||||
})
|
||||
.where("scrimPostId", "=", args.scrimPostId)
|
||||
.where("reportedAt", "is", null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Returns the scrim's maps ordered by index ascending. */
|
||||
export function findMapsByScrimPostId(scrimPostId: number) {
|
||||
return db
|
||||
.selectFrom("ScrimMap")
|
||||
.select(["id", "index", "mode", "stageId", "winnerSide", "reportedAt"])
|
||||
.where("scrimPostId", "=", scrimPostId)
|
||||
.orderBy("index", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* If a pool can be derived from the submitted map lists and no unreported map
|
||||
* is currently waiting, generates and inserts the next map. Runs entirely
|
||||
* within the caller's transaction so the read of the existing maps and the
|
||||
* insert see a consistent snapshot and no two concurrent report/submit actions
|
||||
* can insert a "next" map at the same index.
|
||||
*/
|
||||
export async function tryGenerateAndInsertNextMapInTrx(
|
||||
trx: Transaction<DB>,
|
||||
scrimPostId: number,
|
||||
): Promise<void> {
|
||||
const mapLists = await ScrimMapListRepository.findMapListsByScrimPostId(
|
||||
scrimPostId,
|
||||
trx,
|
||||
);
|
||||
if (mapLists.length === 0) return;
|
||||
|
||||
const pool = ScrimMapByMap.unionPool(mapLists);
|
||||
if (pool.isEmpty()) return;
|
||||
|
||||
const maps = await trx
|
||||
.selectFrom("ScrimMap")
|
||||
.select(["index", "mode", "stageId", "reportedAt"])
|
||||
.where("scrimPostId", "=", scrimPostId)
|
||||
.execute();
|
||||
|
||||
if (maps.some((m) => m.reportedAt === null)) return;
|
||||
|
||||
const next = ScrimMapByMap.generateNextMap({
|
||||
pool,
|
||||
history: maps.map((m) => ({ mode: m.mode, stageId: m.stageId })),
|
||||
});
|
||||
|
||||
await trx
|
||||
.insertInto("ScrimMap")
|
||||
.values({
|
||||
scrimPostId,
|
||||
index: Scrim.nextMapIndex(maps),
|
||||
mode: next.mode,
|
||||
stageId: next.stageId,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
|
@ -307,6 +307,11 @@ const mapDBRowToScrimPost = (
|
|||
MANAGE_REQUESTS: managerIds,
|
||||
DELETE_POST: managerIds,
|
||||
CANCEL: managerIds.concat(requests.at(0)?.users.map((u) => u.id) ?? []),
|
||||
MANAGE_TRACKING: someRequestIsAccepted
|
||||
? users
|
||||
.map((u) => u.id)
|
||||
.concat(requests[0]?.users.map((u) => u.id) ?? [])
|
||||
: [],
|
||||
},
|
||||
managedByAnyone: Boolean(row.managedByAnyone),
|
||||
canceled,
|
||||
|
|
|
|||
|
|
@ -1,67 +1,240 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { databaseTimestampToDate } from "../../../utils/dates";
|
||||
import { errorToast } from "../../../utils/remix.server";
|
||||
import { requireUser } from "../../auth/core/user.server";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import * as ScrimMapByMap from "../core/ScrimMapByMap";
|
||||
import * as ScrimMapListRepository from "../ScrimMapListRepository.server";
|
||||
import * as ScrimMapRepository from "../ScrimMapRepository.server";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
import { cancelScrimSchema } from "../scrims-schemas";
|
||||
import { scrimIdActionSchema } from "../scrims-schemas";
|
||||
import { parseMapPoolInput } from "../scrims-utils";
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { id } = parseParams({ params, schema: idObject });
|
||||
const post = notFoundIfFalsy(await ScrimPostRepository.findById(id));
|
||||
|
||||
const user = requireUser();
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: cancelScrimSchema,
|
||||
schema: scrimIdActionSchema,
|
||||
});
|
||||
|
||||
requirePermission(post, "CANCEL");
|
||||
requirePermission(post, "MANAGE_TRACKING");
|
||||
|
||||
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
|
||||
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
|
||||
switch (data._action) {
|
||||
case "CANCEL_SCRIM": {
|
||||
requirePermission(post, "CANCEL");
|
||||
|
||||
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
|
||||
errorToast("Cannot cancel a scrim that was already scheduled to start");
|
||||
}
|
||||
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
|
||||
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
|
||||
|
||||
await ScrimPostRepository.cancelScrim(id, {
|
||||
userId: user.id,
|
||||
reason: data.reason,
|
||||
});
|
||||
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
|
||||
errorToast("Cannot cancel a scrim that was already scheduled to start");
|
||||
}
|
||||
|
||||
const acceptedRequest = post.requests.find((r) => r.isAccepted);
|
||||
if (acceptedRequest) {
|
||||
const postTeamName = Scrim.sideDisplayName(post);
|
||||
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
|
||||
await ScrimPostRepository.cancelScrim(id, {
|
||||
userId: user.id,
|
||||
reason: data.reason,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: post.users.map((m) => m.id),
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SCRIM_CANCELED",
|
||||
meta: { id: post.id, opponentTeamName: requestTeamName },
|
||||
},
|
||||
});
|
||||
const acceptedRequest = post.requests.find((r) => r.isAccepted);
|
||||
if (acceptedRequest) {
|
||||
const postTeamName = Scrim.sideDisplayName(post);
|
||||
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
|
||||
|
||||
notify({
|
||||
userIds: acceptedRequest.users.map((m) => m.id),
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SCRIM_CANCELED",
|
||||
meta: { id: post.id, opponentTeamName: postTeamName },
|
||||
},
|
||||
});
|
||||
notify({
|
||||
userIds: post.users.map((m) => m.id),
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SCRIM_CANCELED",
|
||||
meta: { id: post.id, opponentTeamName: requestTeamName },
|
||||
},
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: acceptedRequest.users.map((m) => m.id),
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SCRIM_CANCELED",
|
||||
meta: { id: post.id, opponentTeamName: postTeamName },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "SUBMIT_MAP_LIST": {
|
||||
const { viewerSide } = await loadMapByMapContext({ post, user });
|
||||
|
||||
if (data.source === "FROM_POST") {
|
||||
errorToastIfFalsy(post.mapsTournament, "Post has no tournament to use");
|
||||
}
|
||||
|
||||
const serializedPool =
|
||||
data.source === "POOL"
|
||||
? (parseMapPoolInput(data.serializedPool!)?.serialized ?? null)
|
||||
: null;
|
||||
|
||||
errorToastIfFalsy(
|
||||
data.source !== "POOL" || serializedPool,
|
||||
"Invalid map pool",
|
||||
);
|
||||
|
||||
const resolvedSource: "POOL" | "TOURNAMENT" =
|
||||
data.source === "POOL" ? "POOL" : "TOURNAMENT";
|
||||
|
||||
await ScrimMapListRepository.submitMapListAndGenerateIfNeeded({
|
||||
scrimPostId: post.id,
|
||||
side: viewerSide,
|
||||
source: resolvedSource,
|
||||
tournamentId:
|
||||
data.source === "FROM_POST"
|
||||
? post.mapsTournament!.id
|
||||
: (data.tournamentId ?? null),
|
||||
serializedPool,
|
||||
});
|
||||
|
||||
broadcastRevalidate({ post, user });
|
||||
break;
|
||||
}
|
||||
case "REMOVE_MAP_LIST": {
|
||||
const { viewerSide } = await loadMapByMapContext({ post, user });
|
||||
|
||||
await ScrimMapListRepository.deleteMapList(post.id, viewerSide);
|
||||
|
||||
broadcastRevalidate({ post, user });
|
||||
break;
|
||||
}
|
||||
case "REPORT_MAP": {
|
||||
const { maps } = await loadMapByMapContext({ post, user });
|
||||
|
||||
const target = maps.find((m) => m.id === data.mapId);
|
||||
errorToastIfFalsy(target, "Map not found");
|
||||
errorToastIfFalsy(target!.reportedAt === null, "Map already reported");
|
||||
|
||||
await ScrimMapRepository.reportMapAndGenerateNext({
|
||||
scrimPostId: post.id,
|
||||
mapId: data.mapId,
|
||||
winnerSide: data.winnerSide,
|
||||
reportedByUserId: user.id,
|
||||
});
|
||||
|
||||
broadcastRevalidate({ post, user });
|
||||
break;
|
||||
}
|
||||
case "UNDO_MAP": {
|
||||
const { maps } = await loadMapByMapContext({ post, user });
|
||||
|
||||
const latest = Scrim.lastReportedMap(maps);
|
||||
errorToastIfFalsy(ScrimMapByMap.canUndo(latest, maps), "Nothing to undo");
|
||||
|
||||
await ScrimMapRepository.undoMostRecentMap(post.id);
|
||||
|
||||
broadcastRevalidate({ post, user });
|
||||
break;
|
||||
}
|
||||
case "REPLAY_MAP": {
|
||||
const { maps } = await loadMapByMapContext({ post, user });
|
||||
|
||||
const latest = Scrim.lastReportedMap(maps);
|
||||
errorToastIfFalsy(latest, "No map to replay");
|
||||
|
||||
const currentMap = maps.find((m) => m.reportedAt === null);
|
||||
errorToastIfFalsy(currentMap, "No current map to replace");
|
||||
|
||||
await ScrimMapRepository.replaceCurrentMap({
|
||||
scrimPostId: post.id,
|
||||
mode: latest!.mode,
|
||||
stageId: latest!.stageId,
|
||||
});
|
||||
|
||||
broadcastMapChange({ post, type: "MAP_REPLAYED", user });
|
||||
break;
|
||||
}
|
||||
case "PICK_MAP": {
|
||||
const { maps } = await loadMapByMapContext({ post, user });
|
||||
|
||||
const currentMap = maps.find((m) => m.reportedAt === null);
|
||||
errorToastIfFalsy(currentMap, "No current map to replace");
|
||||
|
||||
await ScrimMapRepository.replaceCurrentMap({
|
||||
scrimPostId: post.id,
|
||||
mode: data.mode,
|
||||
stageId: data.stageId,
|
||||
});
|
||||
|
||||
broadcastMapChange({ post, type: "MAP_PICKED", user });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
async function loadMapByMapContext({
|
||||
post,
|
||||
user,
|
||||
}: {
|
||||
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
|
||||
user: ReturnType<typeof requireUser>;
|
||||
}) {
|
||||
const viewerSide = Scrim.sideOfUser(post, user.id);
|
||||
|
||||
const [maps, mapLists] = await Promise.all([
|
||||
ScrimMapRepository.findMapsByScrimPostId(post.id),
|
||||
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
|
||||
]);
|
||||
|
||||
if (Scrim.isTrackingLocked(maps, mapLists)) {
|
||||
errorToast("Tracking is locked");
|
||||
}
|
||||
|
||||
return { viewerSide: viewerSide!, maps, mapLists };
|
||||
}
|
||||
|
||||
function broadcastRevalidate({
|
||||
post,
|
||||
user,
|
||||
}: {
|
||||
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
|
||||
user: ReturnType<typeof requireUser>;
|
||||
}) {
|
||||
if (!post.chatCode) return;
|
||||
ChatSystemMessage.send({
|
||||
room: post.chatCode,
|
||||
revalidateOnly: true,
|
||||
authorUserId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastMapChange({
|
||||
post,
|
||||
type,
|
||||
user,
|
||||
}: {
|
||||
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
|
||||
type: "MAP_REPLAYED" | "MAP_PICKED";
|
||||
user: ReturnType<typeof requireUser>;
|
||||
}) {
|
||||
if (!post.chatCode) return;
|
||||
ChatSystemMessage.send({
|
||||
room: post.chatCode,
|
||||
type,
|
||||
context: { name: user.username },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
24
app/features/scrims/components/PickMapDialog.tsx
Normal file
24
app/features/scrims/components/PickMapDialog.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { pickMapFormSchema } from "../scrims-schemas";
|
||||
|
||||
export function PickMapDialog({
|
||||
trigger,
|
||||
heading,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
heading: string;
|
||||
}) {
|
||||
return (
|
||||
<SendouDialog heading={heading} trigger={trigger}>
|
||||
<SendouForm schema={pickMapFormSchema} defaultValues={{ mode: "SZ" }}>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="mode" />
|
||||
<FormField name="stageId" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -78,6 +78,10 @@
|
|||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.tournamentPopoverTrigger {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.textContent {
|
||||
padding-inline: var(--s-4);
|
||||
padding-bottom: var(--s-3);
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ function ScrimTournamentPopover({
|
|||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
className={styles.tournamentPopoverTrigger}
|
||||
data-testid="tournament-popover-trigger"
|
||||
>
|
||||
<Avatar
|
||||
|
|
|
|||
78
app/features/scrims/components/ScrimMapListForm.tsx
Normal file
78
app/features/scrims/components/ScrimMapListForm.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import { submitMapListFormSchema } from "../scrims-schemas";
|
||||
|
||||
type SourceValue = "POOL" | "TOURNAMENT" | "FROM_POST";
|
||||
|
||||
export function ScrimMapListForm() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const postTournament = data.post.mapsTournament;
|
||||
const isPostAuthorSide = data.mapByMap.viewerSide === "ALPHA";
|
||||
const useFromPost = postTournament != null && isPostAuthorSide;
|
||||
const defaultSource: SourceValue = useFromPost ? "FROM_POST" : "TOURNAMENT";
|
||||
|
||||
return (
|
||||
<div data-testid="scrim-map-list-form">
|
||||
<SendouForm
|
||||
title={t("scrims:mapByMap.submitListHeading")}
|
||||
schema={submitMapListFormSchema}
|
||||
submitButtonTestId="submit-map-list-button"
|
||||
fullWidth
|
||||
defaultValues={{ source: defaultSource }}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<SourceField
|
||||
postTournamentName={
|
||||
useFromPost ? (postTournament?.name ?? null) : null
|
||||
}
|
||||
/>
|
||||
<SourceDependentFields />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceField({
|
||||
postTournamentName,
|
||||
}: {
|
||||
postTournamentName: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["forms"]);
|
||||
|
||||
const items = postTournamentName
|
||||
? [
|
||||
{ value: "FROM_POST", label: () => postTournamentName },
|
||||
{
|
||||
value: "POOL",
|
||||
label: () => t("forms:options.scrimMapSource.POOL"),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: "TOURNAMENT",
|
||||
label: () => t("forms:options.scrimMapSource.TOURNAMENT"),
|
||||
},
|
||||
{
|
||||
value: "POOL",
|
||||
label: () => t("forms:options.scrimMapSource.POOL"),
|
||||
},
|
||||
];
|
||||
|
||||
return <FormField name="source" options={items} />;
|
||||
}
|
||||
|
||||
function SourceDependentFields() {
|
||||
const { values } = useFormFieldContext();
|
||||
const source = values.source as SourceValue;
|
||||
|
||||
if (source === "POOL") return <FormField name="serializedPool" />;
|
||||
if (source === "TOURNAMENT") return <FormField name="tournamentId" />;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.intro {
|
||||
text-align: center;
|
||||
color: var(--color-text-high);
|
||||
max-width: 32rem;
|
||||
font-size: var(--font-xs);
|
||||
margin-inline: auto;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
max-width: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mapListsSummary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.mapListRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
.mapListRowHeader {
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.mapListBody {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.mapListRowMissing {
|
||||
font-style: italic;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
116
app/features/scrims/components/ScrimMapListManager.tsx
Normal file
116
app/features/scrims/components/ScrimMapListManager.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Map as MapIcon, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { SecondaryAction } from "~/components/match-page/SecondaryAction";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import type { ScrimSide } from "../scrims-types";
|
||||
import { ScrimMapListForm } from "./ScrimMapListForm";
|
||||
import styles from "./ScrimMapListManager.module.css";
|
||||
|
||||
interface Props {
|
||||
viewerSide: ScrimSide;
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
export function ScrimMapListManager({ viewerSide, standalone }: Props) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const ownList = data.mapByMap.mapLists.find((l) => l.side === viewerSide);
|
||||
const [isOpen, setIsOpen] = useState(() => !ownList);
|
||||
|
||||
return (
|
||||
<SecondaryAction
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
collapsedLabel={t("scrims:mapByMap.manageMapLists")}
|
||||
collapsedIcon={<MapIcon size={16} />}
|
||||
standalone={standalone}
|
||||
>
|
||||
<div className={styles.root}>
|
||||
{ownList ? null : <ScrimMapListForm />}
|
||||
<MapListsSummary viewerSide={viewerSide} />
|
||||
</div>
|
||||
</SecondaryAction>
|
||||
);
|
||||
}
|
||||
|
||||
function MapListsSummary({ viewerSide }: { viewerSide: ScrimSide }) {
|
||||
const { t } = useTranslation(["scrims", "q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const lists = data.mapByMap.mapLists;
|
||||
|
||||
const sides: ScrimSide[] = ["ALPHA", "BRAVO"];
|
||||
|
||||
return (
|
||||
<div className={styles.mapListsSummary}>
|
||||
{sides.map((side) => {
|
||||
const list = lists.find((l) => l.side === side);
|
||||
const isOwn = side === viewerSide;
|
||||
return (
|
||||
<div
|
||||
key={side}
|
||||
className={styles.mapListRow}
|
||||
data-testid={`map-list-row-${side}`}
|
||||
>
|
||||
<div className={styles.mapListRowHeader}>
|
||||
{side === "ALPHA"
|
||||
? t("q:match.sides.alpha")
|
||||
: t("q:match.sides.bravo")}
|
||||
</div>
|
||||
<div className={styles.mapListBody}>
|
||||
{list ? (
|
||||
<>
|
||||
<MapListDisplay
|
||||
tournament={list.tournament}
|
||||
mapCount={list.mapList.length}
|
||||
/>
|
||||
{isOwn ? <RemoveOwnListButton /> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.mapListRowMissing}>
|
||||
{t("scrims:mapByMap.noListYet")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveOwnListButton() {
|
||||
const { t } = useTranslation(["scrims", "common"]);
|
||||
return (
|
||||
<FormWithConfirm
|
||||
fields={[["_action", "REMOVE_MAP_LIST"]]}
|
||||
dialogHeading={t("scrims:mapByMap.removeListConfirm")}
|
||||
submitButtonText={t("common:actions.remove")}
|
||||
submitButtonTestId="remove-list-button"
|
||||
>
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<Trash2 size={16} />}
|
||||
aria-label={t("scrims:mapByMap.removeList")}
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
function MapListDisplay({
|
||||
tournament,
|
||||
mapCount,
|
||||
}: {
|
||||
tournament: { id: number; name: string } | undefined;
|
||||
mapCount: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
if (tournament) {
|
||||
return <span>{tournament.name}</span>;
|
||||
}
|
||||
return <span>{t("scrims:mapByMap.poolList", { count: mapCount })}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.locked {
|
||||
padding: var(--s-3);
|
||||
border-radius: var(--radius-box);
|
||||
background: var(--color-bg-high);
|
||||
color: var(--color-text-high);
|
||||
text-align: center;
|
||||
}
|
||||
155
app/features/scrims/components/ScrimMatchActionTab.tsx
Normal file
155
app/features/scrims/components/ScrimMatchActionTab.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { MapPin, Repeat, Undo2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useLoaderData } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouTabPanel } from "~/components/elements/Tabs";
|
||||
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
|
||||
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import * as ScrimMapByMap from "../core/ScrimMapByMap";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import type { ScrimSide } from "../scrims-types";
|
||||
import { PickMapDialog } from "./PickMapDialog";
|
||||
import { ScrimMapListManager } from "./ScrimMapListManager";
|
||||
import styles from "./ScrimMatchActionTab.module.css";
|
||||
|
||||
const ALPHA_TEAM_ID = 1;
|
||||
const BRAVO_TEAM_ID = 2;
|
||||
|
||||
export function ScrimMatchActionTab() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
const viewerSide = user ? Scrim.sideOfUser(data.post, user.id) : null;
|
||||
|
||||
if (data.mapByMap.locked) return null;
|
||||
if (!viewerSide) return <NotParticipantSection />;
|
||||
|
||||
if (!data.mapByMap.currentMap) {
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.ACTION}>
|
||||
<ScrimMapListManager viewerSide={viewerSide} standalone />
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return <ReportMapSection viewerSide={viewerSide} />;
|
||||
}
|
||||
|
||||
function NotParticipantSection() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.ACTION}>
|
||||
<div className={styles.locked}>
|
||||
{t("scrims:mapByMap.nonParticipantNotice")}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const map = data.mapByMap!.currentMap!;
|
||||
const acceptedRequest = data.post.requests.find((r) => r.isAccepted)!;
|
||||
|
||||
const alphaName = data.post.team
|
||||
? Scrim.sideDisplayName(data.post)
|
||||
: t("q:match.groupAlpha");
|
||||
const bravoName = acceptedRequest.team
|
||||
? Scrim.sideDisplayName(acceptedRequest)
|
||||
: t("q:match.groupBravo");
|
||||
|
||||
const ownTeamId = viewerSide === "ALPHA" ? ALPHA_TEAM_ID : BRAVO_TEAM_ID;
|
||||
|
||||
return (
|
||||
<MatchActionTab
|
||||
key={map.id}
|
||||
teams={[
|
||||
{ id: ALPHA_TEAM_ID, name: alphaName },
|
||||
{ id: BRAVO_TEAM_ID, name: bravoName },
|
||||
]}
|
||||
ownTeamId={ownTeamId}
|
||||
stageId={map.stageId}
|
||||
mode={map.mode}
|
||||
withPoints={false}
|
||||
isSubmitting={fetcher.state !== "idle"}
|
||||
onSubmit={({ winnerId }) => {
|
||||
fetcher.submit(
|
||||
{
|
||||
_action: "REPORT_MAP",
|
||||
mapId: String(map.id),
|
||||
winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO",
|
||||
},
|
||||
{ method: "post" },
|
||||
);
|
||||
}}
|
||||
actionButtons={<MapActionButtons />}
|
||||
secondaryAction={<ScrimMapListManager viewerSide={viewerSide} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MapActionButtons() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const undoFetcher = useFetcher();
|
||||
const replayFetcher = useFetcher();
|
||||
|
||||
const maps = data.mapByMap?.maps ?? [];
|
||||
const currentMap = data.mapByMap?.currentMap;
|
||||
const latest = Scrim.lastReportedMap(maps);
|
||||
const undoAllowed = ScrimMapByMap.canUndo(latest, maps);
|
||||
const replayAllowed = Boolean(latest && currentMap);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SendouButton
|
||||
testId="undo-map-button"
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<Undo2 size={16} />}
|
||||
isPending={undoFetcher.state !== "idle"}
|
||||
isDisabled={!undoAllowed}
|
||||
onPress={() => {
|
||||
undoFetcher.submit({ _action: "UNDO_MAP" }, { method: "post" });
|
||||
}}
|
||||
>
|
||||
{t("scrims:mapByMap.undo")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
testId="replay-map-button"
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={<Repeat size={16} />}
|
||||
isPending={replayFetcher.state !== "idle"}
|
||||
isDisabled={!replayAllowed}
|
||||
onPress={() => {
|
||||
replayFetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" });
|
||||
}}
|
||||
>
|
||||
{t("scrims:mapByMap.replay")}
|
||||
</SendouButton>
|
||||
<PickMapDialog
|
||||
key={
|
||||
currentMap
|
||||
? `${currentMap.id}-${currentMap.mode}-${currentMap.stageId}`
|
||||
: "no-map"
|
||||
}
|
||||
heading={t("scrims:mapByMap.pickDialog.heading")}
|
||||
trigger={
|
||||
<SendouButton
|
||||
testId="pick-map-button"
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={<MapPin size={16} />}
|
||||
>
|
||||
{t("scrims:mapByMap.pick")}
|
||||
</SendouButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
import { sub } from "date-fns";
|
||||
import { Ban, Swords } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Image } from "~/components/Image";
|
||||
import { useLoaderData } from "react-router";
|
||||
import {
|
||||
IconBanner,
|
||||
MatchBanner,
|
||||
MatchBannerContainer,
|
||||
} from "~/components/match-page/MatchBanner";
|
||||
import { MatchBannerScheduledTime } from "~/components/match-page/MatchBannerScheduledTime";
|
||||
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import type { ScrimPost } from "../scrims-types";
|
||||
|
||||
export function ScrimMatchBanner() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
|
@ -26,9 +26,12 @@ export function ScrimMatchBanner() {
|
|||
|
||||
const screenLegal = !data.anyUserPrefersNoScreen;
|
||||
|
||||
const topRow = <ScrimMatchBannerTopRow />;
|
||||
|
||||
if (data.post.canceled) {
|
||||
return (
|
||||
<MatchBannerContainer>
|
||||
{topRow}
|
||||
<IconBanner
|
||||
icon={<Ban size={32} />}
|
||||
header={t("scrims:banner.canceled.header", {
|
||||
|
|
@ -42,8 +45,6 @@ export function ScrimMatchBanner() {
|
|||
);
|
||||
}
|
||||
|
||||
const hasMaps = data.post.maps || data.tournamentMapPool;
|
||||
|
||||
const acceptedRequest = data.post.requests[0];
|
||||
const activeRoomLink = resolveActiveRoomLink({
|
||||
roomLinks: data.roomLinks,
|
||||
|
|
@ -54,53 +55,51 @@ export function ScrimMatchBanner() {
|
|||
members: [...data.post.users, ...acceptedRequest.users],
|
||||
});
|
||||
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
|
||||
const joinPool = Scrim.resolvePoolCode(data.post.id);
|
||||
|
||||
const currentMap = data.mapByMap.currentMap;
|
||||
|
||||
if (currentMap) {
|
||||
return (
|
||||
<MatchBannerContainer>
|
||||
{topRow}
|
||||
<MatchBanner
|
||||
stageId={currentMap.stageId}
|
||||
mode={currentMap.mode}
|
||||
screenLegal={screenLegal}
|
||||
joinPool={joinPool}
|
||||
joinViaQr={joinViaQr}
|
||||
/>
|
||||
</MatchBannerContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MatchBannerContainer>
|
||||
{topRow}
|
||||
<IconBanner
|
||||
icon={<Swords size={32} />}
|
||||
header={t("scrims:banner.freeForm.header")}
|
||||
subtitle={t("scrims:banner.freeForm.subtitle")}
|
||||
screenLegal={screenLegal}
|
||||
joinPool={Scrim.resolvePoolCode(data.post.id)}
|
||||
joinPool={joinPool}
|
||||
joinViaQr={joinViaQr}
|
||||
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);
|
||||
function ScrimMatchBannerTopRow() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
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;
|
||||
};
|
||||
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
|
||||
const scheduledAt = databaseTimestampToDate(
|
||||
acceptedRequest?.at ?? data.post.at,
|
||||
);
|
||||
|
||||
return (
|
||||
<Link to={mapsPageWithMapPool(mapPool())}>
|
||||
<Image
|
||||
path={navIconUrl("maps")}
|
||||
width={32}
|
||||
height={32}
|
||||
alt="Generate maplist"
|
||||
/>
|
||||
</Link>
|
||||
<MatchBannerTopRow>
|
||||
<MatchBannerScheduledTime time={scheduledAt} />
|
||||
</MatchBannerTopRow>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ 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 * as Scrim from "../core/Scrim";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import { cancelScrimSchema } from "../scrims-schemas";
|
||||
import { cancelScrimFormSchema } from "../scrims-schemas";
|
||||
|
||||
export function ScrimMatchHeader() {
|
||||
const { t } = useTranslation(["common", "scrims"]);
|
||||
|
|
@ -16,13 +16,20 @@ export function ScrimMatchHeader() {
|
|||
|
||||
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();
|
||||
|
||||
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
|
||||
const viewerSide = data.mapByMap.viewerSide;
|
||||
const opponentSide =
|
||||
viewerSide === "ALPHA"
|
||||
? acceptedRequest
|
||||
: viewerSide === "BRAVO"
|
||||
? data.post
|
||||
: acceptedRequest;
|
||||
|
||||
return (
|
||||
<MatchPageHeader
|
||||
subtitle={t("scrims:page.scheduledScrim")}
|
||||
|
|
@ -42,18 +49,11 @@ export function ScrimMatchHeader() {
|
|||
) : undefined
|
||||
}
|
||||
>
|
||||
<TimePopover
|
||||
time={databaseTimestampToDate(scrimTime)}
|
||||
options={{
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
className="text-left"
|
||||
/>
|
||||
{opponentSide
|
||||
? t("scrims:page.vs", {
|
||||
opponent: Scrim.sideDisplayName(opponentSide),
|
||||
})
|
||||
: null}
|
||||
</MatchPageHeader>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ export function ScrimMatchHeader() {
|
|||
function CancelScrimForm() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={cancelScrimSchema}
|
||||
schema={cancelScrimFormSchema}
|
||||
submitButtonTestId="cancel-scrim-submit"
|
||||
>
|
||||
{({ FormField }) => <FormField name="reason" />}
|
||||
|
|
|
|||
49
app/features/scrims/components/ScrimMatchStatsTab.module.css
Normal file
49
app/features/scrims/components/ScrimMatchStatsTab.module.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.labelCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cellNum {
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-high);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stageModeLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
|
||||
& > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stageImage {
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
206
app/features/scrims/components/ScrimMatchStatsTab.tsx
Normal file
206
app/features/scrims/components/ScrimMatchStatsTab.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { SendouTabPanel } from "~/components/elements/Tabs";
|
||||
import { ModeImage, StageImage } from "~/components/Image";
|
||||
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
|
||||
import { Table } from "~/components/Table";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import * as ScrimMapByMap from "../core/ScrimMapByMap";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import styles from "./ScrimMatchStatsTab.module.css";
|
||||
|
||||
type View = "MODE" | "STAGE" | "BOTH";
|
||||
|
||||
const VIEW_OPTIONS: View[] = ["MODE", "STAGE", "BOTH"];
|
||||
|
||||
export function ScrimMatchStatsTab() {
|
||||
const { t } = useTranslation(["scrims", "game-misc"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const viewerSide = data.mapByMap?.viewerSide;
|
||||
const maps = data.mapByMap?.maps ?? [];
|
||||
const ownPool = data.mapByMap?.ownPool
|
||||
? new MapPool(data.mapByMap.ownPool)
|
||||
: null;
|
||||
|
||||
const [view, setView] = React.useState<View>("BOTH");
|
||||
const [restrictToPool, setRestrictToPool] = React.useState(Boolean(ownPool));
|
||||
|
||||
if (!viewerSide || maps.length === 0) {
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.STATS}>
|
||||
<div className={styles.empty}>{t("scrims:mapByMap.stats.empty")}</div>
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const restrictPool = restrictToPool && ownPool ? ownPool : undefined;
|
||||
|
||||
const stats = ScrimMapByMap.stats(maps, viewerSide, {
|
||||
restrictToPool: restrictPool,
|
||||
});
|
||||
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.STATS}>
|
||||
<div className={styles.root} data-testid="scrim-stats-root">
|
||||
<div className={styles.controls}>
|
||||
<SendouChipRadioGroup>
|
||||
{VIEW_OPTIONS.map((option) => (
|
||||
<SendouChipRadio
|
||||
key={option}
|
||||
name="scrim-stats-view"
|
||||
value={option}
|
||||
checked={view === option}
|
||||
onChange={(value) => setView(value as View)}
|
||||
>
|
||||
{t(`scrims:mapByMap.stats.view.${option}` as const)}
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
{ownPool ? (
|
||||
<label className={styles.toggleRow}>
|
||||
<SendouSwitch
|
||||
isSelected={restrictToPool}
|
||||
onChange={setRestrictToPool}
|
||||
/>
|
||||
{t("scrims:mapByMap.stats.restrictToPool")}
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{view === "MODE" ? (
|
||||
<StatsTable
|
||||
rows={stats.byMode.map((r) => ({
|
||||
key: r.key,
|
||||
label: (
|
||||
<span className={styles.stageModeLabel}>
|
||||
<ModeImage mode={r.key as ModeShort} size={20} />
|
||||
{t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, {
|
||||
defaultValue: r.key,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
wins: r.wins,
|
||||
losses: r.losses,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "STAGE" ? (
|
||||
<StatsTable
|
||||
rows={stats.byStage.map((r) => {
|
||||
const stageId = Number(r.key);
|
||||
return {
|
||||
key: r.key,
|
||||
label: (
|
||||
<span className={styles.stageModeLabel}>
|
||||
<StageImage
|
||||
stageId={stageId as StageId}
|
||||
width={36}
|
||||
className={styles.stageImage}
|
||||
/>
|
||||
{t(`game-misc:STAGE_${stageId}` as const, {
|
||||
defaultValue: r.key,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
wins: r.wins,
|
||||
losses: r.losses,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "BOTH" ? (
|
||||
<StatsTable
|
||||
rows={stats.byStageMode.map((r) => {
|
||||
const [stageId, mode] = r.key.split("-");
|
||||
const stageLabel = t(
|
||||
`game-misc:STAGE_${Number(stageId)}` as const,
|
||||
{ defaultValue: stageId },
|
||||
);
|
||||
return {
|
||||
key: r.key,
|
||||
label: (
|
||||
<span className={styles.stageModeLabel}>
|
||||
<ModeImage mode={mode as ModeShort} size={20} />
|
||||
<StageImage
|
||||
stageId={Number(stageId) as StageId}
|
||||
width={36}
|
||||
className={styles.stageImage}
|
||||
/>
|
||||
{stageLabel}
|
||||
</span>
|
||||
),
|
||||
wins: r.wins,
|
||||
losses: r.losses,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsTable({
|
||||
rows,
|
||||
}: {
|
||||
rows: Array<{
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
wins: number;
|
||||
losses: number;
|
||||
}>;
|
||||
}) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>{t("scrims:mapByMap.stats.empty")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedRows = [...rows]
|
||||
.map((row) => ({ ...row, winRate: row.wins / (row.wins + row.losses) }))
|
||||
.sort((a, b) => {
|
||||
if (b.winRate !== a.winRate) return b.winRate - a.winRate;
|
||||
return b.wins + b.losses - (a.wins + a.losses);
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("scrims:mapByMap.stats.col.label")}</th>
|
||||
<th className={styles.cellNum}>
|
||||
{t("scrims:mapByMap.stats.col.wins")}
|
||||
</th>
|
||||
<th className={styles.cellNum}>
|
||||
{t("scrims:mapByMap.stats.col.losses")}
|
||||
</th>
|
||||
<th className={styles.cellNum}>
|
||||
{t("scrims:mapByMap.stats.col.winPct")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.labelCell}>{row.label}</td>
|
||||
<td className={styles.cellNum}>{row.wins}</td>
|
||||
<td className={styles.cellNum}>{row.losses}</td>
|
||||
<td className={styles.cellNum}>{Math.round(row.winRate * 100)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,20 +2,27 @@ import { sub } from "date-fns";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
|
||||
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
|
||||
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
|
||||
import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs";
|
||||
import type { TimelineMap } from "~/components/match-page/MatchTimeline";
|
||||
import { resolveRoomPass } from "~/components/match-page/utils";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import {
|
||||
resolveActiveRoomLink,
|
||||
useConfirmRoom,
|
||||
} from "~/features/chat/room-link-utils";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
databaseTimestampToJavascriptTimestamp,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import type { ScrimPost } from "../scrims-types";
|
||||
import { ScrimMatchActionTab } from "./ScrimMatchActionTab";
|
||||
import { ScrimMatchStatsTab } from "./ScrimMatchStatsTab";
|
||||
|
||||
export function ScrimMatchTabs() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
|
@ -35,8 +42,10 @@ export function ScrimMatchTabs() {
|
|||
members: allMembers,
|
||||
});
|
||||
|
||||
const tabs = resolveTabs(data);
|
||||
|
||||
return (
|
||||
<MatchTabs tabs={[TAB_KEYS.ROSTERS, TAB_KEYS.JOIN]}>
|
||||
<MatchTabs tabs={tabs}>
|
||||
<MatchJoinTab
|
||||
{...activeRoomLink}
|
||||
onConfirmRoom={onConfirmRoom}
|
||||
|
|
@ -60,10 +69,54 @@ export function ScrimMatchTabs() {
|
|||
},
|
||||
]}
|
||||
/>
|
||||
<ScrimMatchActionTab />
|
||||
<MatchResultTab
|
||||
teams={{
|
||||
alpha: {
|
||||
name: data.post.team
|
||||
? Scrim.sideDisplayName(data.post)
|
||||
: t("q:match.groupAlpha"),
|
||||
avatar: data.post.team?.avatarUrl ?? undefined,
|
||||
},
|
||||
bravo: {
|
||||
name: acceptedRequest.team
|
||||
? Scrim.sideDisplayName(acceptedRequest)
|
||||
: t("q:match.groupBravo"),
|
||||
avatar: acceptedRequest.team?.avatarUrl ?? undefined,
|
||||
},
|
||||
}}
|
||||
maps={resolveTimelineMaps(data, acceptedRequest)}
|
||||
isOngoing={!data.mapByMap?.locked && data.mapByMap?.currentMap !== null}
|
||||
/>
|
||||
<ScrimMatchStatsTab />
|
||||
</MatchTabs>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTabs(data: ReturnType<typeof useLoaderData<typeof loader>>) {
|
||||
const tabs: Array<(typeof TAB_KEYS)[keyof typeof TAB_KEYS]> = [
|
||||
TAB_KEYS.ROSTERS,
|
||||
TAB_KEYS.JOIN,
|
||||
];
|
||||
|
||||
if (!data.mapByMap?.locked) {
|
||||
tabs.push(TAB_KEYS.ACTION);
|
||||
}
|
||||
|
||||
if (data.mapByMap && data.mapByMap.maps.length > 0) {
|
||||
tabs.push(TAB_KEYS.RESULT);
|
||||
}
|
||||
|
||||
if (
|
||||
data.mapByMap?.maps.some((m) => m.reportedAt !== null) &&
|
||||
data.mapByMap.viewerSide !== null
|
||||
) {
|
||||
tabs.push(TAB_KEYS.STATS);
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function mapTeam(team: ScrimPost["team"]) {
|
||||
if (!team) return undefined;
|
||||
return {
|
||||
|
|
@ -73,3 +126,23 @@ function mapTeam(team: ScrimPost["team"]) {
|
|||
avatar: team.avatarUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTimelineMaps(
|
||||
data: ReturnType<typeof useLoaderData<typeof loader>>,
|
||||
acceptedRequest: ScrimPost["requests"][number],
|
||||
): TimelineMap[] {
|
||||
const rosters = {
|
||||
alpha: data.post.users,
|
||||
bravo: acceptedRequest.users,
|
||||
};
|
||||
|
||||
return (data.mapByMap?.maps ?? [])
|
||||
.filter((m) => m.winnerSide !== null && m.reportedAt !== null)
|
||||
.map((map) => ({
|
||||
stageId: map.stageId,
|
||||
mode: map.mode,
|
||||
timestamp: databaseTimestampToJavascriptTimestamp(map.reportedAt!),
|
||||
winner: map.winnerSide === "ALPHA" ? "ALPHA" : "BRAVO",
|
||||
rosters,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants";
|
||||
import type { ScrimFilters, ScrimPost } from "../scrims-types";
|
||||
import {
|
||||
applyFilters,
|
||||
isTrackingLocked,
|
||||
participantIdsListFromAccepted,
|
||||
sideDisplayName,
|
||||
sideOfUser,
|
||||
} from "./Scrim";
|
||||
|
||||
type MockUser = { id: number };
|
||||
|
|
@ -125,7 +128,12 @@ describe("applyFilters", () => {
|
|||
isScheduledForFuture: false,
|
||||
managedByAnyone: false,
|
||||
mapsTournament: null,
|
||||
permissions: { MANAGE_REQUESTS: [], CANCEL: [], DELETE_POST: [] },
|
||||
permissions: {
|
||||
MANAGE_REQUESTS: [],
|
||||
CANCEL: [],
|
||||
DELETE_POST: [],
|
||||
MANAGE_TRACKING: [],
|
||||
},
|
||||
team: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -461,3 +469,84 @@ describe("applyFilters", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sideOfUser", () => {
|
||||
it("returns ALPHA for users in the post's users list", () => {
|
||||
const post = createPost(
|
||||
[{ id: 1 }],
|
||||
[{ isAccepted: true, users: [{ id: 2 }] }],
|
||||
);
|
||||
expect(sideOfUser(post, 1)).toBe("ALPHA");
|
||||
});
|
||||
|
||||
it("returns BRAVO for users in the accepted request's users list", () => {
|
||||
const post = createPost(
|
||||
[{ id: 1 }],
|
||||
[{ isAccepted: true, users: [{ id: 2 }] }],
|
||||
);
|
||||
expect(sideOfUser(post, 2)).toBe("BRAVO");
|
||||
});
|
||||
|
||||
it("returns null for non-participants", () => {
|
||||
const post = createPost(
|
||||
[{ id: 1 }],
|
||||
[{ isAccepted: true, users: [{ id: 2 }] }],
|
||||
);
|
||||
expect(sideOfUser(post, 99)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores users only in non-accepted requests", () => {
|
||||
const post = createPost(
|
||||
[{ id: 1 }],
|
||||
[{ isAccepted: false, users: [{ id: 2 }] }],
|
||||
);
|
||||
expect(sideOfUser(post, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrackingLocked", () => {
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const lockWindowMs = SCRIM_TRACKING_AUTO_LOCK_HOURS * ONE_HOUR_MS;
|
||||
|
||||
it("returns false when no map list submitted yet", () => {
|
||||
expect(isTrackingLocked([], [], Date.now())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false just inside the auto-lock window from list submission", () => {
|
||||
const now = 1_000_000_000;
|
||||
const updatedAt = (now - (lockWindowMs - ONE_HOUR_MS)) / 1000;
|
||||
expect(isTrackingLocked([], [{ updatedAt }], now)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true just past the auto-lock window from list submission", () => {
|
||||
const now = 1_000_000_000;
|
||||
const updatedAt = (now - (lockWindowMs + ONE_HOUR_MS)) / 1000;
|
||||
expect(isTrackingLocked([], [{ updatedAt }], now)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the most recent reported map as the reference point", () => {
|
||||
const now = 1_000_000_000;
|
||||
const oldUpdatedAt = (now - lockWindowMs * 2) / 1000;
|
||||
const recentMapSeconds = (now - ONE_HOUR_MS) / 1000;
|
||||
expect(
|
||||
isTrackingLocked(
|
||||
[{ reportedAt: recentMapSeconds }],
|
||||
[{ updatedAt: oldUpdatedAt }],
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the most recent list update when there are no reported maps", () => {
|
||||
const now = 1_000_000_000;
|
||||
const oldUpdatedAt = (now - lockWindowMs * 2) / 1000;
|
||||
const recentUpdatedAt = (now - ONE_HOUR_MS) / 1000;
|
||||
expect(
|
||||
isTrackingLocked(
|
||||
[],
|
||||
[{ updatedAt: oldUpdatedAt }, { updatedAt: recentUpdatedAt }],
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { format, isWeekend } from "date-fns";
|
||||
import * as R from "remeda";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { LUTI_DIVS } from "../scrims-constants";
|
||||
import type { ScrimFilters, ScrimPost } from "../scrims-types";
|
||||
import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants";
|
||||
import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types";
|
||||
|
||||
/** Returns true if the original poster has accepted any of the requests. */
|
||||
export function isAccepted(post: ScrimPost) {
|
||||
|
|
@ -125,3 +126,69 @@ export function defaultFilters(): ScrimFilters {
|
|||
export function filtersAreDefault(filters: ScrimFilters): boolean {
|
||||
return R.isShallowEqual(filters, defaultFilters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the side ("ALPHA" or "BRAVO") the user belongs to in the scrim, or
|
||||
* null when the user is not part of the accepted pairing.
|
||||
*
|
||||
* The post's own users list is treated as the ALPHA side; the accepted
|
||||
* request's users list is treated as the BRAVO side.
|
||||
*/
|
||||
export function sideOfUser(post: ScrimPost, userId: number): ScrimSide | null {
|
||||
if (post.users.some((u) => u.id === userId)) return "ALPHA";
|
||||
|
||||
const acceptedRequest = post.requests.find((r) => r.isAccepted);
|
||||
if (acceptedRequest?.users.some((u) => u.id === userId)) return "BRAVO";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when map-by-map tracking is locked: the auto-lock window has
|
||||
* elapsed since the last activity (most recent reported map, falling back to
|
||||
* the most recently updated submitted map list). Returns false when no map
|
||||
* list has been submitted yet (tracking is not active).
|
||||
*/
|
||||
export function isTrackingLocked(
|
||||
maps: Pick<Tables["ScrimMap"], "reportedAt">[] = [],
|
||||
mapLists: Pick<Tables["ScrimMapList"], "updatedAt">[] = [],
|
||||
now: number = Date.now(),
|
||||
): boolean {
|
||||
const latestReported = R.firstBy(
|
||||
maps.filter((m) => m.reportedAt !== null),
|
||||
[(m) => m.reportedAt!, "desc"],
|
||||
);
|
||||
const latestList = R.firstBy(mapLists, [(l) => l.updatedAt, "desc"]);
|
||||
|
||||
const referenceSeconds =
|
||||
latestReported?.reportedAt ?? latestList?.updatedAt ?? null;
|
||||
if (referenceSeconds === null) return false;
|
||||
|
||||
const elapsedHours = (now - referenceSeconds * 1000) / (60 * 60 * 1000);
|
||||
|
||||
return elapsedHours > SCRIM_TRACKING_AUTO_LOCK_HOURS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next 0-based map index to be inserted given a list of existing
|
||||
* maps. Existing maps need not be in any particular order.
|
||||
*/
|
||||
export function nextMapIndex(
|
||||
maps: Pick<Tables["ScrimMap"], "index">[],
|
||||
): number {
|
||||
const latest = R.firstBy(maps, [(m) => m.index, "desc"]);
|
||||
return latest ? latest.index + 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recently reported map (by `index`), or undefined if no map
|
||||
* has been reported yet.
|
||||
*/
|
||||
export function lastReportedMap<
|
||||
T extends Pick<Tables["ScrimMap"], "index" | "reportedAt">,
|
||||
>(maps: T[]): T | undefined {
|
||||
return R.firstBy(
|
||||
maps.filter((m) => m.reportedAt !== null),
|
||||
[(m) => m.index, "desc"],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
266
app/features/scrims/core/ScrimMapByMap.test.ts
Normal file
266
app/features/scrims/core/ScrimMapByMap.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { stagesObj } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { canUndo, generateNextMap, stats, unionPool } from "./ScrimMapByMap";
|
||||
|
||||
type MapRow = Pick<
|
||||
Tables["ScrimMap"],
|
||||
"index" | "mode" | "stageId" | "winnerSide" | "reportedAt"
|
||||
>;
|
||||
|
||||
function makeMap(overrides: Partial<MapRow> & { index: number }): MapRow {
|
||||
return {
|
||||
index: overrides.index,
|
||||
mode: overrides.mode ?? "SZ",
|
||||
stageId: overrides.stageId ?? stagesObj.SCORCH_GORGE,
|
||||
winnerSide: overrides.winnerSide ?? null,
|
||||
reportedAt: overrides.reportedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScrimMapByMap.unionPool", () => {
|
||||
it("deduplicates stage-mode pairs across multiple lists", () => {
|
||||
const pool = unionPool([
|
||||
{
|
||||
mapList: [
|
||||
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE as StageId },
|
||||
{ mode: "SZ", stageId: stagesObj.EELTAIL_ALLEY as StageId },
|
||||
],
|
||||
},
|
||||
{
|
||||
mapList: [
|
||||
{ mode: "SZ", stageId: stagesObj.EELTAIL_ALLEY as StageId },
|
||||
{ mode: "SZ", stageId: stagesObj.MAKOMART as StageId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect([...pool.parsed.SZ].sort((a, b) => a - b)).toEqual(
|
||||
[
|
||||
stagesObj.SCORCH_GORGE,
|
||||
stagesObj.EELTAIL_ALLEY,
|
||||
stagesObj.MAKOMART,
|
||||
].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges entries across modes", () => {
|
||||
const pool = unionPool([
|
||||
{
|
||||
mapList: [
|
||||
{ mode: "SZ", stageId: stagesObj.HAMMERHEAD_BRIDGE as StageId },
|
||||
{ mode: "TC", stageId: stagesObj.MAKOMART as StageId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pool.parsed.SZ).toEqual([stagesObj.HAMMERHEAD_BRIDGE]);
|
||||
expect(pool.parsed.TC).toEqual([stagesObj.MAKOMART]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScrimMapByMap.generateNextMap", () => {
|
||||
it("avoids the just-played stage when alternatives exist", () => {
|
||||
const pool = new MapPool({
|
||||
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART, stagesObj.WAHOO_WORLD],
|
||||
TC: [],
|
||||
CB: [],
|
||||
RM: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const next = generateNextMap({
|
||||
pool,
|
||||
history: [{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE }],
|
||||
});
|
||||
expect(next.stageId).not.toBe(stagesObj.SCORCH_GORGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("advances from the last played mode when a mode was replayed", () => {
|
||||
const pool = new MapPool({
|
||||
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART],
|
||||
TC: [stagesObj.HAMMERHEAD_BRIDGE],
|
||||
RM: [stagesObj.WAHOO_WORLD],
|
||||
CB: [stagesObj.EELTAIL_ALLEY],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const next = generateNextMap({
|
||||
pool,
|
||||
history: [
|
||||
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE },
|
||||
{ mode: "SZ", stageId: stagesObj.MAKOMART },
|
||||
],
|
||||
});
|
||||
|
||||
expect(next.mode).toBe("TC");
|
||||
});
|
||||
|
||||
it("advances mode rotation after a manual pick inside the pool", () => {
|
||||
const pool = new MapPool({
|
||||
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART],
|
||||
TC: [stagesObj.HAMMERHEAD_BRIDGE],
|
||||
RM: [stagesObj.WAHOO_WORLD],
|
||||
CB: [stagesObj.EELTAIL_ALLEY],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const next = generateNextMap({
|
||||
pool,
|
||||
history: [
|
||||
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE },
|
||||
{ mode: "RM", stageId: stagesObj.WAHOO_WORLD },
|
||||
],
|
||||
});
|
||||
|
||||
expect(next.mode).toBe("CB");
|
||||
});
|
||||
|
||||
it("falls back to the pool's first mode after a manual pick outside the pool's modes", () => {
|
||||
const pool = new MapPool({
|
||||
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART],
|
||||
TC: [stagesObj.HAMMERHEAD_BRIDGE],
|
||||
RM: [],
|
||||
CB: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const next = generateNextMap({
|
||||
pool,
|
||||
history: [
|
||||
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE },
|
||||
{ mode: "TW", stageId: stagesObj.WAHOO_WORLD },
|
||||
],
|
||||
});
|
||||
|
||||
expect(next.mode).toBe("SZ");
|
||||
});
|
||||
|
||||
it("can still generate when only one stage is available", () => {
|
||||
const pool = new MapPool({
|
||||
SZ: [stagesObj.SCORCH_GORGE],
|
||||
TC: [],
|
||||
CB: [],
|
||||
RM: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const next = generateNextMap({ pool, history: [] });
|
||||
expect(next).toEqual({ mode: "SZ", stageId: stagesObj.SCORCH_GORGE });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScrimMapByMap.canUndo", () => {
|
||||
it("returns true for the most recent reported map", () => {
|
||||
const history = [
|
||||
makeMap({ index: 0, reportedAt: 100 }),
|
||||
makeMap({ index: 1, reportedAt: 200 }),
|
||||
];
|
||||
|
||||
expect(canUndo(history[1], history)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unreported maps", () => {
|
||||
const history = [makeMap({ index: 0, reportedAt: null })];
|
||||
expect(canUndo(history[0], history)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a non-latest reported map", () => {
|
||||
const history = [
|
||||
makeMap({ index: 0, reportedAt: 100 }),
|
||||
makeMap({ index: 1, reportedAt: 200 }),
|
||||
];
|
||||
expect(canUndo(history[0], history)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when given undefined", () => {
|
||||
expect(canUndo(undefined, [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when an unreported next map exists after the latest reported", () => {
|
||||
const history = [
|
||||
makeMap({ index: 0, reportedAt: 100 }),
|
||||
makeMap({ index: 1, reportedAt: 200 }),
|
||||
makeMap({ index: 2, reportedAt: null }),
|
||||
];
|
||||
|
||||
expect(canUndo(history[1], history)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScrimMapByMap.stats", () => {
|
||||
const history: MapRow[] = [
|
||||
makeMap({
|
||||
index: 0,
|
||||
mode: "SZ",
|
||||
stageId: stagesObj.SCORCH_GORGE,
|
||||
winnerSide: "ALPHA",
|
||||
reportedAt: 100,
|
||||
}),
|
||||
makeMap({
|
||||
index: 1,
|
||||
mode: "SZ",
|
||||
stageId: stagesObj.MAKOMART,
|
||||
winnerSide: "BRAVO",
|
||||
reportedAt: 200,
|
||||
}),
|
||||
makeMap({
|
||||
index: 2,
|
||||
mode: "TC",
|
||||
stageId: stagesObj.MAKOMART,
|
||||
winnerSide: "ALPHA",
|
||||
reportedAt: 300,
|
||||
}),
|
||||
makeMap({
|
||||
index: 3,
|
||||
mode: "SZ",
|
||||
stageId: stagesObj.SCORCH_GORGE,
|
||||
winnerSide: null,
|
||||
reportedAt: null,
|
||||
}),
|
||||
];
|
||||
|
||||
it("aggregates wins/losses from the viewer's perspective", () => {
|
||||
const result = stats(history, "ALPHA");
|
||||
|
||||
const szMode = result.byMode.find((r) => r.key === "SZ");
|
||||
expect(szMode).toEqual({ key: "SZ", wins: 1, losses: 1 });
|
||||
|
||||
const tcMode = result.byMode.find((r) => r.key === "TC");
|
||||
expect(tcMode).toEqual({ key: "TC", wins: 1, losses: 0 });
|
||||
});
|
||||
|
||||
it("flips wins/losses when viewing as BRAVO", () => {
|
||||
const result = stats(history, "BRAVO");
|
||||
|
||||
const szMode = result.byMode.find((r) => r.key === "SZ");
|
||||
expect(szMode).toEqual({ key: "SZ", wins: 1, losses: 1 });
|
||||
|
||||
const tcMode = result.byMode.find((r) => r.key === "TC");
|
||||
expect(tcMode).toEqual({ key: "TC", wins: 0, losses: 1 });
|
||||
});
|
||||
|
||||
it("filters out empty rows", () => {
|
||||
const result = stats(history, "ALPHA");
|
||||
for (const row of result.byMode) {
|
||||
expect(row.wins + row.losses).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects restrictToPool", () => {
|
||||
const restrictToPool = new MapPool({
|
||||
SZ: [stagesObj.SCORCH_GORGE],
|
||||
TC: [],
|
||||
CB: [],
|
||||
RM: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const result = stats(history, "ALPHA", { restrictToPool });
|
||||
expect(result.byMode).toEqual([{ key: "SZ", wins: 1, losses: 0 }]);
|
||||
});
|
||||
});
|
||||
174
app/features/scrims/core/ScrimMapByMap.ts
Normal file
174
app/features/scrims/core/ScrimMapByMap.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import * as MapList from "~/features/map-list-generator/core/MapList";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { MapPoolObject } from "~/features/map-list-generator/core/map-pool-serializer/types";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
|
||||
type ResolvedMapListRow = {
|
||||
mapList: Array<{ mode: ModeShort; stageId: StageId }>;
|
||||
};
|
||||
|
||||
type ScrimMapRow = Pick<
|
||||
Tables["ScrimMap"],
|
||||
"index" | "mode" | "stageId" | "winnerSide" | "reportedAt"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Merges the submitted map lists into a single deduplicated MapPool.
|
||||
*/
|
||||
export function unionPool(lists: ResolvedMapListRow[]): MapPool {
|
||||
const buckets: Record<ModeShort, Set<StageId>> = {
|
||||
TW: new Set(),
|
||||
SZ: new Set(),
|
||||
TC: new Set(),
|
||||
RM: new Set(),
|
||||
CB: new Set(),
|
||||
};
|
||||
|
||||
for (const list of lists) {
|
||||
for (const { mode, stageId } of list.mapList) {
|
||||
buckets[mode].add(stageId);
|
||||
}
|
||||
}
|
||||
|
||||
const merged: MapPoolObject = {
|
||||
TW: [...buckets.TW],
|
||||
SZ: [...buckets.SZ],
|
||||
TC: [...buckets.TC],
|
||||
RM: [...buckets.RM],
|
||||
CB: [...buckets.CB],
|
||||
};
|
||||
|
||||
return new MapPool(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next single map for the scrim, keeping the pool's mode order
|
||||
* stable across calls and avoiding already-played `(mode, stage)` pairs.
|
||||
*/
|
||||
export function generateNextMap(args: {
|
||||
pool: MapPool;
|
||||
history: Pick<Tables["ScrimMap"], "mode" | "stageId">[];
|
||||
}): { mode: ModeShort; stageId: StageId } {
|
||||
if (args.pool.isEmpty()) {
|
||||
throw new Error("Cannot generate map from empty pool");
|
||||
}
|
||||
|
||||
const generator = MapList.resume({
|
||||
mapPool: args.pool,
|
||||
history: args.history,
|
||||
});
|
||||
|
||||
generator.next();
|
||||
const result = generator.next({ amount: 1 }).value;
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
throw new Error("Failed to generate map");
|
||||
}
|
||||
|
||||
return { mode: result[0].mode, stageId: result[0].stageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the given map is the most recently reported one and is
|
||||
* therefore eligible to be undone.
|
||||
*/
|
||||
export function canUndo(
|
||||
map: ScrimMapRow | undefined,
|
||||
history: ScrimMapRow[],
|
||||
): boolean {
|
||||
if (!map || map.reportedAt === null) return false;
|
||||
|
||||
for (const other of history) {
|
||||
if (other.reportedAt === null) continue;
|
||||
if (other.index > map.index) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type StatsRow = {
|
||||
key: string;
|
||||
wins: number;
|
||||
losses: number;
|
||||
};
|
||||
|
||||
export type Stats = {
|
||||
byMode: StatsRow[];
|
||||
byStage: StatsRow[];
|
||||
byStageMode: StatsRow[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregates per-mode, per-stage, and per-(stage, mode) win/loss counts from
|
||||
* the viewer's perspective. Maps outside `restrictToPool` (when provided) are
|
||||
* skipped, as are unreported maps. Empty rows are filtered out.
|
||||
*/
|
||||
export function stats(
|
||||
maps: ScrimMapRow[],
|
||||
viewerSide: "ALPHA" | "BRAVO",
|
||||
opts: { restrictToPool?: MapPool } = {},
|
||||
): Stats {
|
||||
const byMode = new Map<ModeShort, StatsRow>();
|
||||
const byStage = new Map<StageId, StatsRow>();
|
||||
const byStageMode = new Map<string, StatsRow>();
|
||||
|
||||
const stageModeKey = (mode: ModeShort, stageId: StageId) =>
|
||||
`${stageId}-${mode}`;
|
||||
|
||||
const bump = <K>(
|
||||
bucket: Map<K, StatsRow>,
|
||||
key: K,
|
||||
display: string,
|
||||
isWin: boolean,
|
||||
) => {
|
||||
const existing = bucket.get(key);
|
||||
if (existing) {
|
||||
if (isWin) existing.wins += 1;
|
||||
else existing.losses += 1;
|
||||
return;
|
||||
}
|
||||
bucket.set(key, {
|
||||
key: display,
|
||||
wins: isWin ? 1 : 0,
|
||||
losses: isWin ? 0 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
for (const map of maps) {
|
||||
if (map.reportedAt === null || map.winnerSide === null) continue;
|
||||
if (
|
||||
opts.restrictToPool &&
|
||||
!opts.restrictToPool.has({ mode: map.mode, stageId: map.stageId })
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isWin = map.winnerSide === viewerSide;
|
||||
|
||||
bump(byMode, map.mode, map.mode, isWin);
|
||||
bump(byStage, map.stageId, String(map.stageId), isWin);
|
||||
bump(
|
||||
byStageMode,
|
||||
stageModeKey(map.mode, map.stageId),
|
||||
stageModeKey(map.mode, map.stageId),
|
||||
isWin,
|
||||
);
|
||||
}
|
||||
|
||||
const filterEmpty = (rows: StatsRow[]) =>
|
||||
rows.filter((r) => r.wins + r.losses > 0);
|
||||
|
||||
const orderedByMode: StatsRow[] = [];
|
||||
for (const mode of modesShort) {
|
||||
const row = byMode.get(mode);
|
||||
if (row) orderedByMode.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
byMode: filterEmpty(orderedByMode),
|
||||
byStage: filterEmpty([...byStage.values()]),
|
||||
byStageMode: filterEmpty([...byStageMode.values()]),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +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";
|
||||
import { notFoundIfFalsy } from "../../../utils/remix.server";
|
||||
|
|
@ -10,6 +9,9 @@ import {
|
|||
requireUser,
|
||||
} from "../../auth/core/user.server";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import * as ScrimMapByMap from "../core/ScrimMapByMap";
|
||||
import * as ScrimMapListRepository from "../ScrimMapListRepository.server";
|
||||
import * as ScrimMapRepository from "../ScrimMapRepository.server";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
|
@ -36,6 +38,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
RoomLinkRepository.findByUserIds(participantIds, 3),
|
||||
]);
|
||||
|
||||
const mapByMap = await resolveMapByMap({ post, user });
|
||||
|
||||
return {
|
||||
post,
|
||||
chatCode:
|
||||
|
|
@ -50,17 +54,38 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
anyUserPrefersNoScreen,
|
||||
anyUserPrefersNoSplatnet,
|
||||
roomLinks,
|
||||
tournamentMapPool: post.mapsTournament
|
||||
? await resolveTournamentMapPool(post.mapsTournament.id, user)
|
||||
: null,
|
||||
mapByMap,
|
||||
};
|
||||
};
|
||||
|
||||
async function resolveTournamentMapPool(
|
||||
tournamentId: number,
|
||||
user: AuthenticatedUser,
|
||||
) {
|
||||
const data = await tournamentDataCached({ tournamentId, user });
|
||||
async function resolveMapByMap({
|
||||
post,
|
||||
user,
|
||||
}: {
|
||||
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
|
||||
user: AuthenticatedUser;
|
||||
}) {
|
||||
const [mapLists, maps] = await Promise.all([
|
||||
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
|
||||
ScrimMapRepository.findMapsByScrimPostId(post.id),
|
||||
]);
|
||||
|
||||
return data.ctx.toSetMapPool;
|
||||
const pool = mapLists.length > 0 ? ScrimMapByMap.unionPool(mapLists) : null;
|
||||
const currentMap = maps.find((m) => m.reportedAt === null) ?? null;
|
||||
const viewerSide = Scrim.sideOfUser(post, user.id);
|
||||
const locked = Scrim.isTrackingLocked(maps, mapLists);
|
||||
|
||||
const ownList = viewerSide
|
||||
? mapLists.find((l) => l.side === viewerSide)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
mapLists,
|
||||
maps,
|
||||
currentMap,
|
||||
viewerSide,
|
||||
locked,
|
||||
pool: pool ? pool.stageModePairs : null,
|
||||
ownPool: ownList?.mapList ?? null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ export const SCRIM = {
|
|||
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
|
||||
ROOM_LINK_FRESHNESS_MINUTES: 30,
|
||||
};
|
||||
|
||||
export const SCRIM_TRACKING_AUTO_LOCK_HOURS = 4;
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@ import {
|
|||
datetimeRequired,
|
||||
dualSelectOptional,
|
||||
idConstant,
|
||||
radioGroupDynamic,
|
||||
select,
|
||||
selectDynamicOptional,
|
||||
selectOptional,
|
||||
stageSelect,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
textAreaRequired,
|
||||
textFieldOptional,
|
||||
timeRangeOptional,
|
||||
toggle,
|
||||
tournamentSearchOptional,
|
||||
} from "~/form/fields";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import {
|
||||
_action,
|
||||
date,
|
||||
|
|
@ -26,6 +31,7 @@ import {
|
|||
} from "~/utils/zod";
|
||||
import { associationIdentifierSchema } from "../associations/associations-schemas";
|
||||
import { LUTI_DIVS, SCRIM } from "./scrims-constants";
|
||||
import { parseMapPoolInput } from "./scrims-utils";
|
||||
|
||||
const deletePostSchema = z.object({
|
||||
_action: _action("DELETE_POST"),
|
||||
|
|
@ -71,7 +77,8 @@ const cancelRequestSchema = z.object({
|
|||
scrimPostRequestId: id,
|
||||
});
|
||||
|
||||
export const cancelScrimSchema = z.object({
|
||||
export const cancelScrimFormSchema = z.object({
|
||||
_action: stringConstant("CANCEL_SCRIM"),
|
||||
reason: textAreaRequired({
|
||||
label: "labels.scrimCancelReason",
|
||||
bottomText: "bottomTexts.scrimCancelReasonHelp",
|
||||
|
|
@ -174,6 +181,89 @@ export const scrimsActionSchema = z.union([
|
|||
persistScrimFiltersSchema,
|
||||
]);
|
||||
|
||||
export const submitMapListFormSchema = z
|
||||
.object({
|
||||
_action: stringConstant("SUBMIT_MAP_LIST"),
|
||||
source: radioGroupDynamic({
|
||||
label: "labels.scrimMapSource",
|
||||
}),
|
||||
serializedPool: textFieldOptional({
|
||||
label: "labels.scrimMapPool",
|
||||
placeholder: "placeholders.scrimMapPool",
|
||||
maxLength: 500,
|
||||
validate: {
|
||||
func: (val) => parseMapPoolInput(val) !== null,
|
||||
message: "forms:errors.invalidMapPool",
|
||||
},
|
||||
}),
|
||||
tournamentId: tournamentSearchOptional({
|
||||
label: "labels.scrimMapsTournament",
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!["POOL", "TOURNAMENT", "FROM_POST"].includes(data.source)) {
|
||||
ctx.addIssue({
|
||||
path: ["source"],
|
||||
message: "forms:errors.required",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
if (data.source === "POOL" && !data.serializedPool) {
|
||||
ctx.addIssue({
|
||||
path: ["serializedPool"],
|
||||
message: "forms:errors.invalidMapPool",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
if (data.source === "TOURNAMENT" && !data.tournamentId) {
|
||||
ctx.addIssue({
|
||||
path: ["tournamentId"],
|
||||
message: "forms:errors.scrimTournamentRequired",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const removeMapListSchema = z.object({
|
||||
_action: _action("REMOVE_MAP_LIST"),
|
||||
});
|
||||
|
||||
const reportMapSchema = z.object({
|
||||
_action: _action("REPORT_MAP"),
|
||||
mapId: id,
|
||||
winnerSide: z.enum(["ALPHA", "BRAVO"]),
|
||||
});
|
||||
|
||||
const undoMapSchema = z.object({
|
||||
_action: _action("UNDO_MAP"),
|
||||
});
|
||||
|
||||
const replayMapSchema = z.object({
|
||||
_action: _action("REPLAY_MAP"),
|
||||
});
|
||||
|
||||
export const pickMapFormSchema = z.object({
|
||||
_action: stringConstant("PICK_MAP"),
|
||||
mode: select({
|
||||
label: "labels.vodMode",
|
||||
items: modesShort.map((m) => ({
|
||||
label: `modes.${m}` as const,
|
||||
value: m,
|
||||
})),
|
||||
}),
|
||||
stageId: stageSelect({ label: "labels.vodStage" }),
|
||||
});
|
||||
|
||||
export const scrimIdActionSchema = z.union([
|
||||
cancelScrimFormSchema,
|
||||
submitMapListFormSchema,
|
||||
removeMapListSchema,
|
||||
reportMapSchema,
|
||||
undoMapSchema,
|
||||
replayMapSchema,
|
||||
pickMapFormSchema,
|
||||
]);
|
||||
|
||||
const MAX_SCRIM_POST_TEXT_LENGTH = 500;
|
||||
|
||||
export const RANGE_END_OPTIONS = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { LUTI_DIVS } from "./scrims-constants";
|
|||
|
||||
export type LutiDiv = (typeof LUTI_DIVS)[number];
|
||||
|
||||
export type ScrimSide = "ALPHA" | "BRAVO";
|
||||
|
||||
export interface ScrimPost {
|
||||
id: number;
|
||||
at: number;
|
||||
|
|
@ -33,6 +35,7 @@ export interface ScrimPost {
|
|||
MANAGE_REQUESTS: number[];
|
||||
DELETE_POST: number[];
|
||||
CANCEL: number[];
|
||||
MANAGE_TRACKING: number[];
|
||||
};
|
||||
managedByAnyone: boolean;
|
||||
/** When the post was made was it scheduled for a future time slot (as opposed to looking now) */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatFlexTimeDisplay, generateTimeOptions } from "./scrims-utils";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
formatFlexTimeDisplay,
|
||||
generateTimeOptions,
|
||||
parseMapPoolInput,
|
||||
} from "./scrims-utils";
|
||||
|
||||
describe("generateTimeOptions", () => {
|
||||
it("includes both start and end times", () => {
|
||||
|
|
@ -233,3 +238,74 @@ describe("formatFlexTimeDisplay", () => {
|
|||
expect(result).toBe("+1h 1m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMapPoolInput", () => {
|
||||
const VALID_POOL = "tw:3330000;sz:3a14000;tc:2c98000;rm:2bc0000;cb:39c0000";
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseMapPoolInput("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(parseMapPoolInput(" \t\n ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the parsed pool is empty", () => {
|
||||
expect(parseMapPoolInput("not-a-valid-pool")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a MapPool for a bare serialized pool", () => {
|
||||
const result = parseMapPoolInput(VALID_POOL);
|
||||
|
||||
expect(result).toBeInstanceOf(MapPool);
|
||||
expect(result?.serialized).toBe(VALID_POOL);
|
||||
});
|
||||
|
||||
it("trims whitespace around a bare serialized pool", () => {
|
||||
const result = parseMapPoolInput(` ${VALID_POOL} `);
|
||||
|
||||
expect(result?.serialized).toBe(VALID_POOL);
|
||||
});
|
||||
|
||||
it("extracts the pool param from a full URL", () => {
|
||||
const result = parseMapPoolInput(
|
||||
`https://sendou.ink/maps?pool=${VALID_POOL}`,
|
||||
);
|
||||
|
||||
expect(result?.serialized).toBe(VALID_POOL);
|
||||
});
|
||||
|
||||
it("returns null for a URL without a pool param", () => {
|
||||
expect(parseMapPoolInput("https://sendou.ink/maps?other=1")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores other URL params when extracting pool", () => {
|
||||
const result = parseMapPoolInput(
|
||||
`https://sendou.ink/maps?foo=bar&pool=${VALID_POOL}&baz=qux`,
|
||||
);
|
||||
|
||||
expect(result?.serialized).toBe(VALID_POOL);
|
||||
});
|
||||
|
||||
it("returns null for a malformed URL with ://", () => {
|
||||
expect(parseMapPoolInput("not a url://")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses the pool value from a query-string fragment", () => {
|
||||
expect(parseMapPoolInput(`pool=${VALID_POOL}`)?.serialized).toBe(
|
||||
VALID_POOL,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops at the next & in a query-string fragment", () => {
|
||||
expect(parseMapPoolInput(`pool=${VALID_POOL}&other=1`)?.serialized).toBe(
|
||||
VALID_POOL,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves leading params before pool= in a query-string fragment", () => {
|
||||
expect(parseMapPoolInput(`foo=bar&pool=${VALID_POOL}`)?.serialized).toBe(
|
||||
VALID_POOL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { differenceInMinutes } from "date-fns";
|
||||
import * as R from "remeda";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import * as Scrim from "./core/Scrim";
|
||||
import type { LutiDiv, ScrimPost } from "./scrims-types";
|
||||
|
|
@ -87,6 +88,19 @@ export function generateTimeOptions(startDate: Date, endDate: Date): number[] {
|
|||
return Array.from(timestamps).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function parseMapPoolInput(input: string): MapPool | null {
|
||||
const serialized = extractSerializedPool(input);
|
||||
if (!serialized) return null;
|
||||
|
||||
try {
|
||||
const pool = new MapPool(serialized);
|
||||
if (pool.isEmpty()) return null;
|
||||
return pool;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFlexTimeDisplay(
|
||||
startTimestamp: number,
|
||||
endTimestamp: number,
|
||||
|
|
@ -110,3 +124,23 @@ export function formatFlexTimeDisplay(
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSerializedPool(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.includes("://")) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return url.searchParams.get("pool");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes("pool=")) {
|
||||
return new URLSearchParams(trimmed).get("pool");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -435,7 +435,9 @@ function InProgressTab({
|
|||
{ method: "post" },
|
||||
);
|
||||
}}
|
||||
weaponReport={isStaffOnly ? undefined : weaponReport}
|
||||
secondaryAction={
|
||||
isStaffOnly ? null : <WeaponReporter {...weaponReport} />
|
||||
}
|
||||
actionButtons={
|
||||
<>
|
||||
{isStaffOnly ? (
|
||||
|
|
@ -469,29 +471,28 @@ function InProgressTab({
|
|||
</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}
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<Undo2 size={16} />}
|
||||
isPending={undoFetcher.state !== "idle"}
|
||||
isDisabled={!scoreIsNotZero}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from "~/components/match-page/MatchBanner";
|
||||
import bannerStyles from "~/components/match-page/MatchBanner.module.css";
|
||||
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
|
||||
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
|
||||
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
|
||||
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
|
||||
|
|
@ -159,18 +161,18 @@ function SendouQMatchBannerTopRow({
|
|||
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)),
|
||||
}
|
||||
}
|
||||
/>
|
||||
>
|
||||
{data.match.isLocked || awaitingConfirmation ? (
|
||||
<MatchBannerStartedAt time={startedAt} />
|
||||
) : (
|
||||
<MatchBannerTimer
|
||||
time={{
|
||||
currentMinutes: Math.max(0, differenceInMinutes(now, lastReportAt)),
|
||||
totalMinutes: Math.max(0, differenceInMinutes(now, startedAt)),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MatchBannerTopRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import clsx from "clsx";
|
||||
import { Undo2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
|
|
@ -130,7 +129,7 @@ export function TournamentMatchActionTab({
|
|||
size="miniscule"
|
||||
icon={<Undo2 size={16} />}
|
||||
isPending={undoFetcher.state !== "idle"}
|
||||
className={clsx({ invisible: scoreSum === 0 })}
|
||||
isDisabled={scoreSum === 0}
|
||||
onPress={() => {
|
||||
undoFetcher.submit(
|
||||
{
|
||||
|
|
@ -145,7 +144,9 @@ export function TournamentMatchActionTab({
|
|||
{t("q:match.undoReport")}
|
||||
</SendouButton>
|
||||
}
|
||||
weaponReport={weaponReport ?? undefined}
|
||||
secondaryAction={
|
||||
weaponReport ? <WeaponReporter {...weaponReport} /> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
} from "~/components/match-page/MatchBanner";
|
||||
import bannerStyles from "~/components/match-page/MatchBanner.module.css";
|
||||
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
|
||||
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
|
||||
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
|
||||
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
|
||||
import type { TournamentRoundMaps } from "~/db/tables";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
|
|
@ -206,10 +208,8 @@ function TournamentMatchBannerTopRow({
|
|||
)
|
||||
return null;
|
||||
|
||||
const totalMinutes = differenceInMinutes(
|
||||
currentTime,
|
||||
databaseTimestampToDate(data.match.startedAt),
|
||||
);
|
||||
const startedAt = databaseTimestampToDate(data.match.startedAt);
|
||||
const totalMinutes = differenceInMinutes(currentTime, startedAt);
|
||||
|
||||
const currentMinutes = resolveCurrentMinutes({
|
||||
data,
|
||||
|
|
@ -228,15 +228,13 @@ function TournamentMatchBannerTopRow({
|
|||
count: data.match.roundMaps.count,
|
||||
bestOf: data.match.roundMaps.type === "BEST_OF",
|
||||
}}
|
||||
time={
|
||||
data.matchIsOver
|
||||
? undefined
|
||||
: {
|
||||
currentMinutes,
|
||||
totalMinutes,
|
||||
}
|
||||
}
|
||||
/>
|
||||
>
|
||||
{data.matchIsOver ? (
|
||||
<MatchBannerStartedAt time={startedAt} />
|
||||
) : (
|
||||
<MatchBannerTimer time={{ currentMinutes, totalMinutes }} />
|
||||
)}
|
||||
</MatchBannerTopRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { StageSelectFormField } from "./fields/StageSelectFormField";
|
|||
import { SwitchFormField } from "./fields/SwitchFormField";
|
||||
import { TextareaFormField } from "./fields/TextareaFormField";
|
||||
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
|
||||
import { TournamentSearchFormField } from "./fields/TournamentSearchFormField";
|
||||
import { UserSearchFormField } from "./fields/UserSearchFormField";
|
||||
import {
|
||||
WeaponPoolFormField,
|
||||
|
|
@ -28,6 +29,7 @@ import type {
|
|||
ArrayItemRenderContext,
|
||||
BadgeOption,
|
||||
CustomFieldRenderProps,
|
||||
FormFieldItemsWithImage,
|
||||
FormField as FormFieldType,
|
||||
SelectOption,
|
||||
} from "./types";
|
||||
|
|
@ -224,6 +226,22 @@ export function FormField({
|
|||
);
|
||||
}
|
||||
|
||||
if (formField.type === "radio-group-dynamic") {
|
||||
if (!options) {
|
||||
throw new Error("Dynamic radio group form field requires options prop");
|
||||
}
|
||||
const radioItems = options as FormFieldItemsWithImage<string>;
|
||||
return (
|
||||
<RadioGroupFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
items={radioItems}
|
||||
value={value as string}
|
||||
onChange={handleChange as (v: string) => void}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (formField.type === "checkbox-group") {
|
||||
return (
|
||||
<CheckboxGroupFormField
|
||||
|
|
@ -371,6 +389,17 @@ export function FormField({
|
|||
);
|
||||
}
|
||||
|
||||
if (formField.type === "tournament-search") {
|
||||
return (
|
||||
<TournamentSearchFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
value={value as number | null}
|
||||
onChange={handleChange as (v: number | null) => void}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (formField.type === "badges") {
|
||||
if (!options) {
|
||||
throw new Error("Badges form field requires options prop");
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
FormFieldFieldset,
|
||||
FormFieldInputGroup,
|
||||
FormFieldItems,
|
||||
FormFieldItemsWithImage,
|
||||
FormFieldSelect,
|
||||
FormsTranslationKey,
|
||||
SelectOption,
|
||||
|
|
@ -32,9 +33,13 @@ export type RequiresDefault<T extends z.ZodType> = T & {
|
|||
_requiresDefault: true;
|
||||
};
|
||||
|
||||
type WithTypedTranslationKeys<T> = Omit<T, "label" | "bottomText"> & {
|
||||
type WithTypedTranslationKeys<T> = Omit<
|
||||
T,
|
||||
"label" | "bottomText" | "placeholder"
|
||||
> & {
|
||||
label?: FormsTranslationKey;
|
||||
bottomText?: FormsTranslationKey;
|
||||
placeholder?: FormsTranslationKey;
|
||||
};
|
||||
|
||||
type WithTypedItemLabels<T, V extends string> = Omit<T, "items"> & {
|
||||
|
|
@ -102,6 +107,7 @@ export function textFieldOptional(
|
|||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
placeholder: prefixKey(args.placeholder),
|
||||
required: false,
|
||||
type: "text-field",
|
||||
initialValue: "",
|
||||
|
|
@ -125,6 +131,7 @@ export function textFieldRequired(
|
|||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
placeholder: prefixKey(args.placeholder),
|
||||
required: true,
|
||||
type: "text-field",
|
||||
initialValue: "",
|
||||
|
|
@ -429,6 +436,24 @@ export function radioGroup<V extends string>(
|
|||
});
|
||||
}
|
||||
|
||||
export function radioGroupDynamic(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "radio-group-dynamic" }>,
|
||||
"type" | "initialValue"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return z.string().register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "radio-group-dynamic",
|
||||
initialValue: null,
|
||||
}) as unknown as z.ZodType<string> &
|
||||
FieldWithOptions<FormFieldItemsWithImage<string>>;
|
||||
}
|
||||
|
||||
type DateTimeArgs = WithTypedTranslationKeys<
|
||||
Omit<FormFieldDatetime<"datetime">, "type" | "initialValue" | "required">
|
||||
> & {
|
||||
|
|
@ -707,6 +732,24 @@ export function userSearchOptional(
|
|||
});
|
||||
}
|
||||
|
||||
export function tournamentSearchOptional(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "tournament-search" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "tournament-search",
|
||||
initialValue: null,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function badges(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<Extract<FormField, { type: "badges" }>, "type" | "initialValue">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FormFieldProps } from "../types";
|
||||
import { ariaAttributes } from "../utils";
|
||||
import { FormFieldWrapper } from "./FormFieldWrapper";
|
||||
|
|
@ -14,6 +15,7 @@ export function InputFormField({
|
|||
label,
|
||||
bottomText,
|
||||
leftAddon,
|
||||
placeholder,
|
||||
maxLength,
|
||||
error,
|
||||
onBlur,
|
||||
|
|
@ -24,6 +26,11 @@ export function InputFormField({
|
|||
onChange,
|
||||
}: InputFormFieldProps) {
|
||||
const id = React.useId();
|
||||
const { t } = useTranslation(["forms"]);
|
||||
|
||||
const translatedPlaceholder = placeholder?.includes(":")
|
||||
? t(placeholder as never)
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
|
|
@ -45,6 +52,7 @@ export function InputFormField({
|
|||
onBlur={() => onBlur?.()}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
placeholder={translatedPlaceholder}
|
||||
{...ariaAttributes({
|
||||
id,
|
||||
bottomText,
|
||||
|
|
|
|||
39
app/form/fields/TournamentSearchFormField.tsx
Normal file
39
app/form/fields/TournamentSearchFormField.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { TournamentSearch } from "~/components/elements/TournamentSearch";
|
||||
import type { FormFieldProps } from "../types";
|
||||
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
|
||||
import styles from "./UserSearchFormField.module.css";
|
||||
|
||||
type TournamentSearchFormFieldProps = FormFieldProps<"tournament-search"> & {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
};
|
||||
|
||||
export function TournamentSearchFormField({
|
||||
name,
|
||||
label,
|
||||
bottomText,
|
||||
error,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: TournamentSearchFormFieldProps) {
|
||||
const { translatedLabel } = useTranslatedTexts({
|
||||
label,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className="stack xs">
|
||||
<TournamentSearch
|
||||
initialTournamentId={value ?? undefined}
|
||||
onChange={(tournament) => onChange(tournament?.id ?? null)}
|
||||
onBlur={() => onBlur?.()}
|
||||
label={translatedLabel}
|
||||
isRequired={required}
|
||||
/>
|
||||
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,14 +24,16 @@ export function UserSearchFormField({
|
|||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<UserSearch
|
||||
initialUserId={value ?? undefined}
|
||||
onChange={(user) => onChange(user?.id ?? null)}
|
||||
onBlur={() => onBlur?.()}
|
||||
label={translatedLabel}
|
||||
isRequired={required}
|
||||
/>
|
||||
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
|
||||
<div className="stack xs">
|
||||
<UserSearch
|
||||
initialUserId={value ?? undefined}
|
||||
onChange={(user) => onChange(user?.id ?? null)}
|
||||
onBlur={() => onBlur?.()}
|
||||
label={translatedLabel}
|
||||
isRequired={required}
|
||||
/>
|
||||
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface FormFieldText<T extends string> extends FormFieldBase<T> {
|
|||
maxLength: number;
|
||||
toLowerCase?: boolean;
|
||||
leftAddon?: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
inputType?: "text" | "number";
|
||||
regExp?: {
|
||||
|
|
@ -139,6 +140,10 @@ interface FormFieldUserSearch<T extends string> extends FormFieldBase<T> {
|
|||
required: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldTournamentSearch<T extends string> extends FormFieldBase<T> {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldBadges<T extends string> extends FormFieldBase<T> {
|
||||
maxCount?: number;
|
||||
}
|
||||
|
|
@ -148,6 +153,11 @@ interface FormFieldSelectDynamic<T extends string> extends FormFieldBase<T> {
|
|||
searchable?: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldRadioGroupDynamic<T extends string>
|
||||
extends FormFieldBase<T> {
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
interface FormFieldStageSelect<T extends string> extends FormFieldBase<T> {
|
||||
required: boolean;
|
||||
}
|
||||
|
|
@ -165,6 +175,7 @@ export type FormField<V extends string = string> =
|
|||
| FormFieldSelectDynamic<"select-dynamic">
|
||||
| FormFieldDualSelect<"dual-select", V>
|
||||
| FormFieldInputGroup<"radio-group", V>
|
||||
| FormFieldRadioGroupDynamic<"radio-group-dynamic">
|
||||
| FormFieldInputGroup<"checkbox-group", V>
|
||||
| FormFieldDatetime<"datetime">
|
||||
| FormFieldDatetime<"date">
|
||||
|
|
@ -178,6 +189,7 @@ export type FormField<V extends string = string> =
|
|||
| FormFieldTimeRange<"time-range">
|
||||
| FormFieldFieldset<"fieldset", z.ZodRawShape>
|
||||
| FormFieldUserSearch<"user-search">
|
||||
| FormFieldTournamentSearch<"tournament-search">
|
||||
| FormFieldBadges<"badges">
|
||||
| FormFieldStageSelect<"stage-select">
|
||||
| FormFieldWeaponSelect<"weapon-select">;
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -1,7 +1,11 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { scrimsNewFormSchema } from "~/features/scrims/scrims-schemas";
|
||||
import { newScrimPostPage, scrimsPage } from "~/utils/urls";
|
||||
import {
|
||||
scrimsNewFormSchema,
|
||||
submitMapListFormSchema,
|
||||
} from "~/features/scrims/scrims-schemas";
|
||||
import { newScrimPostPage, scrimPage, scrimsPage } from "~/utils/urls";
|
||||
import {
|
||||
expect,
|
||||
impersonate,
|
||||
|
|
@ -10,9 +14,12 @@ import {
|
|||
selectUser,
|
||||
submit,
|
||||
test,
|
||||
waitForPOSTResponse,
|
||||
} from "./helpers/playwright";
|
||||
import { createFormHelpers } from "./helpers/playwright-form";
|
||||
|
||||
const TEST_POOL_SERIALIZED = "sz:3a14000;tc:2c98000";
|
||||
|
||||
test.describe("Scrims", () => {
|
||||
test("creates a new scrim & deletes it", async ({ page }) => {
|
||||
await seed(page);
|
||||
|
|
@ -234,9 +241,126 @@ test.describe("Scrims", () => {
|
|||
await page.getByTestId("booked-scrims-tab").click();
|
||||
await page.getByRole("link", { name: "Contact" }).click();
|
||||
|
||||
await page.getByAltText("Generate maplist").click();
|
||||
await page.getByRole("tab", { name: "Action" }).click();
|
||||
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
|
||||
});
|
||||
|
||||
// on /maps page
|
||||
await expect(page.getByText("Create map list")).toBeVisible();
|
||||
test("map-by-map: lists, report, undo, replay, change list, stats", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seed(page);
|
||||
|
||||
const scrimUrl = scrimPage(1);
|
||||
|
||||
const mapListForm = createFormHelpers(page, submitMapListFormSchema, {
|
||||
submitTestId: "submit-map-list-button",
|
||||
});
|
||||
|
||||
// ADMIN opens the Action tab — the map list form is shown immediately
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await navigate({ page, url: scrimUrl });
|
||||
await page.getByRole("tab", { name: "Action" }).click();
|
||||
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
|
||||
|
||||
// ADMIN submits a map list — the post's tournament (Swim or Sink) is the
|
||||
// default source for the scrim author's team, so they can submit without
|
||||
// running the tournament search. A first map is generated immediately
|
||||
// so the page transitions to the report UI with the map-list manager
|
||||
// collapsed.
|
||||
await waitForPOSTResponse(page, () => mapListForm.submit());
|
||||
await expect(page.getByTestId("report-score-button")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Manage map lists/i }).click();
|
||||
await expect(page.getByTestId("map-list-row-ALPHA")).toContainText(
|
||||
"Swim or Sink",
|
||||
);
|
||||
|
||||
// NZAP submits a pool-URL-based map list. They have no list yet so the
|
||||
// map-list manager is already expanded on mount.
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await navigate({ page, url: scrimUrl });
|
||||
await page.getByRole("tab", { name: "Action" }).click();
|
||||
await page.getByLabel("Pool URL").click();
|
||||
await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED);
|
||||
await waitForPOSTResponse(page, () => mapListForm.submit());
|
||||
await expect(page.getByTestId("report-score-button")).toBeVisible();
|
||||
await expect(page.getByTestId("map-list-row-BRAVO")).toContainText("Pool");
|
||||
|
||||
// Map 1: ALPHA wins → next map auto-generated
|
||||
await reportScrimMapWinner(page, "ALPHA");
|
||||
await expect(page.getByTestId("report-score-button")).toBeVisible();
|
||||
|
||||
// Map 2: BRAVO wins → next map auto-generated
|
||||
await reportScrimMapWinner(page, "BRAVO");
|
||||
await expect(page.getByTestId("report-score-button")).toBeVisible();
|
||||
|
||||
// Map 3: ALPHA wins → undo (un-reports map 3, deletes auto-gen map 4)
|
||||
await reportScrimMapWinner(page, "ALPHA");
|
||||
await expect(page.getByTestId("undo-map-button")).toBeVisible();
|
||||
await submit(page, "undo-map-button");
|
||||
await expect(page.getByTestId("report-score-button")).toBeVisible();
|
||||
|
||||
// Re-report map 3 as BRAVO wins → next map auto-generated
|
||||
await reportScrimMapWinner(page, "BRAVO");
|
||||
|
||||
// Replay last map: replaces the current generated map with a copy of
|
||||
// the previous reported one, then report ALPHA wins
|
||||
await expect(page.getByTestId("replay-map-button")).toBeVisible();
|
||||
await submit(page, "replay-map-button");
|
||||
await reportScrimMapWinner(page, "ALPHA");
|
||||
|
||||
// Switch back to ADMIN to change their list
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await navigate({ page, url: scrimUrl });
|
||||
await page.getByRole("tab", { name: "Action" }).click();
|
||||
await page.getByRole("button", { name: /Manage map lists/i }).click();
|
||||
|
||||
// Remove ALPHA's tournament list (trash icon opens a confirm dialog)
|
||||
await page
|
||||
.getByTestId("map-list-row-ALPHA")
|
||||
.getByLabel(/Remove list/i)
|
||||
.click();
|
||||
await waitForPOSTResponse(page, () => submit(page, "confirm-button"));
|
||||
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
|
||||
|
||||
// Re-submit ALPHA's list, this time as a pool URL
|
||||
await page.getByLabel("Pool URL").click();
|
||||
await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED);
|
||||
await waitForPOSTResponse(page, () => mapListForm.submit());
|
||||
await expect(page.getByTestId("map-list-row-ALPHA")).toContainText("Pool");
|
||||
|
||||
// Verify stats tab reflects the played maps
|
||||
await page.getByRole("tab", { name: "Stats" }).click();
|
||||
await expect(page.getByTestId("scrim-stats-root")).toBeVisible();
|
||||
|
||||
// Four reported maps total (Alpha 2 / Bravo 2 from ADMIN's POV).
|
||||
// Switch to "Mode" view so each row groups by mode, and disable the
|
||||
// pool restriction so maps outside ADMIN's resubmitted pool still count.
|
||||
// Sum of wins+losses across rows should equal 4.
|
||||
await page
|
||||
.getByTestId("scrim-stats-root")
|
||||
.getByText("Mode", { exact: true })
|
||||
.click();
|
||||
await page.getByRole("switch").click({ force: true });
|
||||
|
||||
const statsRoot = page.getByTestId("scrim-stats-root");
|
||||
const winCells = await statsRoot
|
||||
.locator("tbody tr td:nth-child(2)")
|
||||
.allInnerTexts();
|
||||
const lossCells = await statsRoot
|
||||
.locator("tbody tr td:nth-child(3)")
|
||||
.allInnerTexts();
|
||||
const total =
|
||||
winCells.reduce((acc, v) => acc + Number(v), 0) +
|
||||
lossCells.reduce((acc, v) => acc + Number(v), 0);
|
||||
expect(total).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
async function reportScrimMapWinner(page: Page, winner: "ALPHA" | "BRAVO") {
|
||||
const testId = winner === "ALPHA" ? "winner-radio-1" : "winner-radio-2";
|
||||
await expect(
|
||||
page.locator('[data-testid^="winner-radio-"][data-selected="true"]'),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId(testId).click();
|
||||
await submit(page, "report-score-button");
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -320,6 +320,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.systemMsg.mapReplayed": "",
|
||||
"chat.systemMsg.mapPicked": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "",
|
||||
"labels.scrimMaxDiv": "",
|
||||
"labels.scrimMinDiv": "",
|
||||
"labels.scrimMapSource": "",
|
||||
"labels.scrimMapPool": "",
|
||||
"labels.scrimMapsTournament": "",
|
||||
"placeholders.scrimMapPool": "",
|
||||
"options.scrimMapSource.POOL": "",
|
||||
"options.scrimMapSource.TOURNAMENT": "",
|
||||
"options.scrimFlexibility.notFlexible": "",
|
||||
"options.scrimFlexibility.+30min": "",
|
||||
"options.scrimFlexibility.+1hour": "",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "",
|
||||
"errors.usersMustBeUnique": "",
|
||||
"errors.divBothOrNeither": "",
|
||||
"errors.invalidMapPool": "",
|
||||
"errors.scrimTournamentRequired": "",
|
||||
"errors.tournamentMustBeSelected": "",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "",
|
||||
"errors.visibilityMustBeDifferent": "",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@
|
|||
"match.tabs.rosters": "",
|
||||
"match.tabs.action": "",
|
||||
"match.tabs.result": "",
|
||||
"match.tabs.stats": "",
|
||||
"preparing.joinQ": "",
|
||||
"tiers.currentCriteria": "",
|
||||
"tiers.info.p1": "",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"forms.maps.tournament": "",
|
||||
"forms.mapsTournament.title": "",
|
||||
"page.scheduledScrim": "",
|
||||
"page.vs": "",
|
||||
"associations.title": "",
|
||||
"associations.explanation": "",
|
||||
"associations.join.title": "",
|
||||
|
|
@ -89,5 +90,30 @@
|
|||
"banner.canceled.header": "",
|
||||
"banner.canceled.subtitle": "",
|
||||
"banner.freeForm.header": "",
|
||||
"banner.freeForm.subtitle": ""
|
||||
"banner.freeForm.subtitle": "",
|
||||
"mapByMap.nonParticipantNotice": "",
|
||||
"mapByMap.noCurrentMap": "",
|
||||
"mapByMap.undo": "",
|
||||
"mapByMap.replay": "",
|
||||
"mapByMap.pick": "",
|
||||
"mapByMap.pickDialog.heading": "",
|
||||
"mapByMap.removeList": "",
|
||||
"mapByMap.removeListConfirm": "",
|
||||
"mapByMap.submitListHeading": "",
|
||||
"mapByMap.noListYet": "",
|
||||
"mapByMap.manageMapLists": "",
|
||||
"mapByMap.poolList": "",
|
||||
"mapByMap.result.replayTag": "",
|
||||
"mapByMap.stats.empty": "",
|
||||
"mapByMap.stats.restrictToPool": "",
|
||||
"mapByMap.stats.byMode": "",
|
||||
"mapByMap.stats.byStage": "",
|
||||
"mapByMap.stats.byStageMode": "",
|
||||
"mapByMap.stats.view.MODE": "",
|
||||
"mapByMap.stats.view.STAGE": "",
|
||||
"mapByMap.stats.view.BOTH": "",
|
||||
"mapByMap.stats.col.label": "",
|
||||
"mapByMap.stats.col.wins": "",
|
||||
"mapByMap.stats.col.losses": "",
|
||||
"mapByMap.stats.col.winPct": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.systemMsg.mapReplayed": "",
|
||||
"chat.systemMsg.mapPicked": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "",
|
||||
"labels.scrimMaxDiv": "",
|
||||
"labels.scrimMinDiv": "",
|
||||
"labels.scrimMapSource": "",
|
||||
"labels.scrimMapPool": "",
|
||||
"labels.scrimMapsTournament": "",
|
||||
"placeholders.scrimMapPool": "",
|
||||
"options.scrimMapSource.POOL": "",
|
||||
"options.scrimMapSource.TOURNAMENT": "",
|
||||
"options.scrimFlexibility.notFlexible": "",
|
||||
"options.scrimFlexibility.+30min": "",
|
||||
"options.scrimFlexibility.+1hour": "",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "",
|
||||
"errors.usersMustBeUnique": "",
|
||||
"errors.divBothOrNeither": "",
|
||||
"errors.invalidMapPool": "",
|
||||
"errors.scrimTournamentRequired": "",
|
||||
"errors.tournamentMustBeSelected": "",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "",
|
||||
"errors.visibilityMustBeDifferent": "",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@
|
|||
"match.tabs.rosters": "",
|
||||
"match.tabs.action": "",
|
||||
"match.tabs.result": "",
|
||||
"match.tabs.stats": "",
|
||||
"preparing.joinQ": "",
|
||||
"tiers.currentCriteria": "",
|
||||
"tiers.info.p1": "",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"forms.maps.tournament": "",
|
||||
"forms.mapsTournament.title": "",
|
||||
"page.scheduledScrim": "Geplanter Scrim",
|
||||
"page.vs": "",
|
||||
"associations.title": "Assoziationen",
|
||||
"associations.explanation": "Erstelle eine Assoziation, um in einer kleineren Gruppe zu suchen (zum Beispiel mit regelmäßgien Übungsgegnern deines Teams oder deiner LUTI-Division).",
|
||||
"associations.join.title": "Assoziation {{name}} beitreten?",
|
||||
|
|
@ -89,5 +90,30 @@
|
|||
"banner.canceled.header": "",
|
||||
"banner.canceled.subtitle": "",
|
||||
"banner.freeForm.header": "",
|
||||
"banner.freeForm.subtitle": ""
|
||||
"banner.freeForm.subtitle": "",
|
||||
"mapByMap.nonParticipantNotice": "",
|
||||
"mapByMap.noCurrentMap": "",
|
||||
"mapByMap.undo": "",
|
||||
"mapByMap.replay": "",
|
||||
"mapByMap.pick": "",
|
||||
"mapByMap.pickDialog.heading": "",
|
||||
"mapByMap.removeList": "",
|
||||
"mapByMap.removeListConfirm": "",
|
||||
"mapByMap.submitListHeading": "",
|
||||
"mapByMap.noListYet": "",
|
||||
"mapByMap.manageMapLists": "",
|
||||
"mapByMap.poolList": "",
|
||||
"mapByMap.result.replayTag": "",
|
||||
"mapByMap.stats.empty": "",
|
||||
"mapByMap.stats.restrictToPool": "",
|
||||
"mapByMap.stats.byMode": "",
|
||||
"mapByMap.stats.byStage": "",
|
||||
"mapByMap.stats.byStageMode": "",
|
||||
"mapByMap.stats.view.MODE": "",
|
||||
"mapByMap.stats.view.STAGE": "",
|
||||
"mapByMap.stats.view.BOTH": "",
|
||||
"mapByMap.stats.col.label": "",
|
||||
"mapByMap.stats.col.wins": "",
|
||||
"mapByMap.stats.col.losses": "",
|
||||
"mapByMap.stats.col.winPct": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked",
|
||||
"chat.systemMsg.cancelRefused": "{{name}} refused canceling the match",
|
||||
"chat.systemMsg.userLeft": "{{name}} left the group",
|
||||
"chat.systemMsg.mapReplayed": "{{name}} replayed the previous map",
|
||||
"chat.systemMsg.mapPicked": "{{name}} picked a map",
|
||||
"chat.newMessages": "New messages",
|
||||
"chat.sidebar.title": "Chat",
|
||||
"chat.sidebar.noActiveChats": "No active chats",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "Maps",
|
||||
"labels.scrimMaxDiv": "Max div",
|
||||
"labels.scrimMinDiv": "Min div",
|
||||
"labels.scrimMapSource": "Source",
|
||||
"labels.scrimMapPool": "Map pool",
|
||||
"labels.scrimMapsTournament": "Tournament",
|
||||
"placeholders.scrimMapPool": "https://sendou.ink/maps?pool=sz%3A3ffffff%3Btc%3A3555555",
|
||||
"options.scrimMapSource.POOL": "Pool URL",
|
||||
"options.scrimMapSource.TOURNAMENT": "Tournament",
|
||||
"options.scrimFlexibility.notFlexible": "Not flexible",
|
||||
"options.scrimFlexibility.+30min": "+30 minutes",
|
||||
"options.scrimFlexibility.+1hour": "+1 hour",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "Must have at least {{min}} users excluding yourself",
|
||||
"errors.usersMustBeUnique": "Users must be unique",
|
||||
"errors.divBothOrNeither": "Both min and max div must be set or neither",
|
||||
"errors.invalidMapPool": "Invalid map pool",
|
||||
"errors.scrimTournamentRequired": "Please select a tournament",
|
||||
"errors.tournamentMustBeSelected": "Tournament must be selected when maps is tournament",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "Tournament should only be selected when maps is tournament",
|
||||
"errors.visibilityMustBeDifferent": "Not found visibility must be different from base visibility",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@
|
|||
"match.tabs.rosters": "Rosters",
|
||||
"match.tabs.action": "Action",
|
||||
"match.tabs.result": "Result",
|
||||
"match.tabs.stats": "Stats",
|
||||
"preparing.joinQ": "Join the queue",
|
||||
"tiers.currentCriteria": "Current criteria",
|
||||
"tiers.info.p1": "For example, Leviathan is the top 5% of players. Diamond is the 85th percentile etc.",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"forms.maps.tournament": "Tournament...",
|
||||
"forms.mapsTournament.title": "Tournament",
|
||||
"page.scheduledScrim": "Scheduled scrim",
|
||||
"page.vs": "vs. {{opponent}}",
|
||||
"associations.title": "Associations",
|
||||
"associations.explanation": "Create an association to look in a smaller group (for example, make one with your team's regular practice opponents or LUTI division).",
|
||||
"associations.join.title": "Join {{name}} association?",
|
||||
|
|
@ -89,5 +90,30 @@
|
|||
"banner.canceled.header": "Canceled by {{user}}",
|
||||
"banner.canceled.subtitle": "Reason: {{reason}}",
|
||||
"banner.freeForm.header": "Free form practice",
|
||||
"banner.freeForm.subtitle": "Communicate the maplist with the opponents"
|
||||
"banner.freeForm.subtitle": "Set a map list to start drawing maps (optional)",
|
||||
"mapByMap.nonParticipantNotice": "Only participants can manage map tracking.",
|
||||
"mapByMap.noCurrentMap": "Waiting for the next map to be generated.",
|
||||
"mapByMap.undo": "Undo",
|
||||
"mapByMap.replay": "Replay",
|
||||
"mapByMap.pick": "Pick",
|
||||
"mapByMap.pickDialog.heading": "Pick map",
|
||||
"mapByMap.removeList": "Remove list",
|
||||
"mapByMap.removeListConfirm": "Remove your map list?",
|
||||
"mapByMap.submitListHeading": "Submit your map list",
|
||||
"mapByMap.noListYet": "Not submitted yet",
|
||||
"mapByMap.manageMapLists": "Manage map lists",
|
||||
"mapByMap.poolList": "Pool ({{count}} maps)",
|
||||
"mapByMap.result.replayTag": "Replay of map {{index}}",
|
||||
"mapByMap.stats.empty": "No reported maps yet",
|
||||
"mapByMap.stats.restrictToPool": "Restrict to my submitted pool",
|
||||
"mapByMap.stats.byMode": "By mode",
|
||||
"mapByMap.stats.byStage": "By stage",
|
||||
"mapByMap.stats.byStageMode": "By stage & mode",
|
||||
"mapByMap.stats.view.MODE": "Mode",
|
||||
"mapByMap.stats.view.STAGE": "Stage",
|
||||
"mapByMap.stats.view.BOTH": "Stage & Mode",
|
||||
"mapByMap.stats.col.label": "Map",
|
||||
"mapByMap.stats.col.wins": "Wins",
|
||||
"mapByMap.stats.col.losses": "Losses",
|
||||
"mapByMap.stats.col.winPct": "Win %"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
"chat.systemMsg.userLeft": "{{name}} abandonó el grupo",
|
||||
"chat.systemMsg.mapReplayed": "",
|
||||
"chat.systemMsg.mapPicked": "",
|
||||
"chat.newMessages": "Nuevos mensajes",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "Mapas",
|
||||
"labels.scrimMaxDiv": "Div. máxima",
|
||||
"labels.scrimMinDiv": "Div. mínima",
|
||||
"labels.scrimMapSource": "",
|
||||
"labels.scrimMapPool": "",
|
||||
"labels.scrimMapsTournament": "",
|
||||
"placeholders.scrimMapPool": "",
|
||||
"options.scrimMapSource.POOL": "",
|
||||
"options.scrimMapSource.TOURNAMENT": "",
|
||||
"options.scrimFlexibility.notFlexible": "Sin flexibilidad",
|
||||
"options.scrimFlexibility.+30min": "+30 minutos",
|
||||
"options.scrimFlexibility.+1hour": "+1 hora",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "Debe haber al menos {{min}} usuarios sin contarte a ti",
|
||||
"errors.usersMustBeUnique": "Los usuarios deben ser únicos",
|
||||
"errors.divBothOrNeither": "Deben establecerse tanto la div. mínima como la máxima, o ninguna",
|
||||
"errors.invalidMapPool": "",
|
||||
"errors.scrimTournamentRequired": "",
|
||||
"errors.tournamentMustBeSelected": "Debe seleccionarse un torneo cuando los mapas son de torneo",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "El torneo solo debe seleccionarse cuando los mapas son de torneo",
|
||||
"errors.visibilityMustBeDifferent": "La visibilidad de 'no encontrado' debe ser diferente a la visibilidad base",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@
|
|||
"match.tabs.rosters": "",
|
||||
"match.tabs.action": "",
|
||||
"match.tabs.result": "",
|
||||
"match.tabs.stats": "",
|
||||
"preparing.joinQ": "Unirte a la fila",
|
||||
"tiers.currentCriteria": "Criterios actuales",
|
||||
"tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"forms.maps.tournament": "Torneo...",
|
||||
"forms.mapsTournament.title": "Torneo",
|
||||
"page.scheduledScrim": "Scrim programado",
|
||||
"page.vs": "",
|
||||
"associations.title": "Asociaciones",
|
||||
"associations.explanation": "Crea una asociación para buscar en un grupo más pequeño (por ejemplo, con los oponentes habituales de tus prácticas o la división LUTI).",
|
||||
"associations.join.title": "¿Unirse a la asociación {{name}}?",
|
||||
|
|
@ -89,5 +90,30 @@
|
|||
"banner.canceled.header": "",
|
||||
"banner.canceled.subtitle": "",
|
||||
"banner.freeForm.header": "",
|
||||
"banner.freeForm.subtitle": ""
|
||||
"banner.freeForm.subtitle": "",
|
||||
"mapByMap.nonParticipantNotice": "",
|
||||
"mapByMap.noCurrentMap": "",
|
||||
"mapByMap.undo": "",
|
||||
"mapByMap.replay": "",
|
||||
"mapByMap.pick": "",
|
||||
"mapByMap.pickDialog.heading": "",
|
||||
"mapByMap.removeList": "",
|
||||
"mapByMap.removeListConfirm": "",
|
||||
"mapByMap.submitListHeading": "",
|
||||
"mapByMap.noListYet": "",
|
||||
"mapByMap.manageMapLists": "",
|
||||
"mapByMap.poolList": "",
|
||||
"mapByMap.result.replayTag": "",
|
||||
"mapByMap.stats.empty": "",
|
||||
"mapByMap.stats.restrictToPool": "",
|
||||
"mapByMap.stats.byMode": "",
|
||||
"mapByMap.stats.byStage": "",
|
||||
"mapByMap.stats.byStageMode": "",
|
||||
"mapByMap.stats.view.MODE": "",
|
||||
"mapByMap.stats.view.STAGE": "",
|
||||
"mapByMap.stats.view.BOTH": "",
|
||||
"mapByMap.stats.col.label": "",
|
||||
"mapByMap.stats.col.wins": "",
|
||||
"mapByMap.stats.col.losses": "",
|
||||
"mapByMap.stats.col.winPct": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar el partido. El partido está cerrado",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
"chat.systemMsg.userLeft": "{{name}} se salió del grupo",
|
||||
"chat.systemMsg.mapReplayed": "",
|
||||
"chat.systemMsg.mapPicked": "",
|
||||
"chat.newMessages": "Nuevo mensajes",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "",
|
||||
"labels.scrimMaxDiv": "",
|
||||
"labels.scrimMinDiv": "",
|
||||
"labels.scrimMapSource": "",
|
||||
"labels.scrimMapPool": "",
|
||||
"labels.scrimMapsTournament": "",
|
||||
"placeholders.scrimMapPool": "",
|
||||
"options.scrimMapSource.POOL": "",
|
||||
"options.scrimMapSource.TOURNAMENT": "",
|
||||
"options.scrimFlexibility.notFlexible": "",
|
||||
"options.scrimFlexibility.+30min": "",
|
||||
"options.scrimFlexibility.+1hour": "",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "",
|
||||
"errors.usersMustBeUnique": "",
|
||||
"errors.divBothOrNeither": "",
|
||||
"errors.invalidMapPool": "",
|
||||
"errors.scrimTournamentRequired": "",
|
||||
"errors.tournamentMustBeSelected": "",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "",
|
||||
"errors.visibilityMustBeDifferent": "",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@
|
|||
"match.tabs.rosters": "",
|
||||
"match.tabs.action": "",
|
||||
"match.tabs.result": "",
|
||||
"match.tabs.stats": "",
|
||||
"preparing.joinQ": "Unirte a la fila",
|
||||
"tiers.currentCriteria": "Criterios actuales",
|
||||
"tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"forms.maps.tournament": "",
|
||||
"forms.mapsTournament.title": "",
|
||||
"page.scheduledScrim": "",
|
||||
"page.vs": "",
|
||||
"associations.title": "",
|
||||
"associations.explanation": "",
|
||||
"associations.join.title": "",
|
||||
|
|
@ -89,5 +90,30 @@
|
|||
"banner.canceled.header": "",
|
||||
"banner.canceled.subtitle": "",
|
||||
"banner.freeForm.header": "",
|
||||
"banner.freeForm.subtitle": ""
|
||||
"banner.freeForm.subtitle": "",
|
||||
"mapByMap.nonParticipantNotice": "",
|
||||
"mapByMap.noCurrentMap": "",
|
||||
"mapByMap.undo": "",
|
||||
"mapByMap.replay": "",
|
||||
"mapByMap.pick": "",
|
||||
"mapByMap.pickDialog.heading": "",
|
||||
"mapByMap.removeList": "",
|
||||
"mapByMap.removeListConfirm": "",
|
||||
"mapByMap.submitListHeading": "",
|
||||
"mapByMap.noListYet": "",
|
||||
"mapByMap.manageMapLists": "",
|
||||
"mapByMap.poolList": "",
|
||||
"mapByMap.result.replayTag": "",
|
||||
"mapByMap.stats.empty": "",
|
||||
"mapByMap.stats.restrictToPool": "",
|
||||
"mapByMap.stats.byMode": "",
|
||||
"mapByMap.stats.byStage": "",
|
||||
"mapByMap.stats.byStageMode": "",
|
||||
"mapByMap.stats.view.MODE": "",
|
||||
"mapByMap.stats.view.STAGE": "",
|
||||
"mapByMap.stats.view.BOTH": "",
|
||||
"mapByMap.stats.col.label": "",
|
||||
"mapByMap.stats.col.wins": "",
|
||||
"mapByMap.stats.col.losses": "",
|
||||
"mapByMap.stats.col.winPct": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.systemMsg.mapReplayed": "",
|
||||
"chat.systemMsg.mapPicked": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "",
|
||||
"labels.scrimMaxDiv": "",
|
||||
"labels.scrimMinDiv": "",
|
||||
"labels.scrimMapSource": "",
|
||||
"labels.scrimMapPool": "",
|
||||
"labels.scrimMapsTournament": "",
|
||||
"placeholders.scrimMapPool": "",
|
||||
"options.scrimMapSource.POOL": "",
|
||||
"options.scrimMapSource.TOURNAMENT": "",
|
||||
"options.scrimFlexibility.notFlexible": "",
|
||||
"options.scrimFlexibility.+30min": "",
|
||||
"options.scrimFlexibility.+1hour": "",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "",
|
||||
"errors.usersMustBeUnique": "",
|
||||
"errors.divBothOrNeither": "",
|
||||
"errors.invalidMapPool": "",
|
||||
"errors.scrimTournamentRequired": "",
|
||||
"errors.tournamentMustBeSelected": "",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "",
|
||||
"errors.visibilityMustBeDifferent": "",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@
|
|||
"match.tabs.rosters": "",
|
||||
"match.tabs.action": "",
|
||||
"match.tabs.result": "",
|
||||
"match.tabs.stats": "",
|
||||
"preparing.joinQ": "",
|
||||
"tiers.currentCriteria": "",
|
||||
"tiers.info.p1": "",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"forms.maps.tournament": "",
|
||||
"forms.mapsTournament.title": "",
|
||||
"page.scheduledScrim": "",
|
||||
"page.vs": "",
|
||||
"associations.title": "",
|
||||
"associations.explanation": "",
|
||||
"associations.join.title": "",
|
||||
|
|
@ -89,5 +90,30 @@
|
|||
"banner.canceled.header": "",
|
||||
"banner.canceled.subtitle": "",
|
||||
"banner.freeForm.header": "",
|
||||
"banner.freeForm.subtitle": ""
|
||||
"banner.freeForm.subtitle": "",
|
||||
"mapByMap.nonParticipantNotice": "",
|
||||
"mapByMap.noCurrentMap": "",
|
||||
"mapByMap.undo": "",
|
||||
"mapByMap.replay": "",
|
||||
"mapByMap.pick": "",
|
||||
"mapByMap.pickDialog.heading": "",
|
||||
"mapByMap.removeList": "",
|
||||
"mapByMap.removeListConfirm": "",
|
||||
"mapByMap.submitListHeading": "",
|
||||
"mapByMap.noListYet": "",
|
||||
"mapByMap.manageMapLists": "",
|
||||
"mapByMap.poolList": "",
|
||||
"mapByMap.result.replayTag": "",
|
||||
"mapByMap.stats.empty": "",
|
||||
"mapByMap.stats.restrictToPool": "",
|
||||
"mapByMap.stats.byMode": "",
|
||||
"mapByMap.stats.byStage": "",
|
||||
"mapByMap.stats.byStageMode": "",
|
||||
"mapByMap.stats.view.MODE": "",
|
||||
"mapByMap.stats.view.STAGE": "",
|
||||
"mapByMap.stats.view.BOTH": "",
|
||||
"mapByMap.stats.col.label": "",
|
||||
"mapByMap.stats.col.wins": "",
|
||||
"mapByMap.stats.col.losses": "",
|
||||
"mapByMap.stats.col.winPct": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@
|
|||
"chat.systemMsg.cancelConfirmed": "{{name}} a confirmé l'anunulation du match. Le match est maintenant vérrouillé",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
"chat.systemMsg.userLeft": "{{name}} a quitté le groupe",
|
||||
"chat.systemMsg.mapReplayed": "",
|
||||
"chat.systemMsg.mapPicked": "",
|
||||
"chat.newMessages": "Nouveau message",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
"labels.scrimMaps": "",
|
||||
"labels.scrimMaxDiv": "",
|
||||
"labels.scrimMinDiv": "",
|
||||
"labels.scrimMapSource": "",
|
||||
"labels.scrimMapPool": "",
|
||||
"labels.scrimMapsTournament": "",
|
||||
"placeholders.scrimMapPool": "",
|
||||
"options.scrimMapSource.POOL": "",
|
||||
"options.scrimMapSource.TOURNAMENT": "",
|
||||
"options.scrimFlexibility.notFlexible": "",
|
||||
"options.scrimFlexibility.+30min": "",
|
||||
"options.scrimFlexibility.+1hour": "",
|
||||
|
|
@ -80,6 +86,8 @@
|
|||
"errors.minUsersExcludingYourself": "",
|
||||
"errors.usersMustBeUnique": "",
|
||||
"errors.divBothOrNeither": "",
|
||||
"errors.invalidMapPool": "",
|
||||
"errors.scrimTournamentRequired": "",
|
||||
"errors.tournamentMustBeSelected": "",
|
||||
"errors.tournamentOnlyWhenMapsIsTournament": "",
|
||||
"errors.visibilityMustBeDifferent": "",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user