mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-23 11:36:19 -05:00
Resolve conflicts and merge main into current branch
This commit is contained in:
@@ -11,6 +11,8 @@ interface LocaleTimeProps {
|
||||
className?: string;
|
||||
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
|
||||
inline?: boolean;
|
||||
/** Optional test id forwarded to the rendered `<time>` element. */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,7 @@ export function LocaleTime({
|
||||
options,
|
||||
className,
|
||||
inline,
|
||||
"data-testid": testId,
|
||||
}: LocaleTimeProps) {
|
||||
const { formatter, isLoaded } = useDateTimeFormat(options);
|
||||
|
||||
@@ -33,9 +36,9 @@ export function LocaleTime({
|
||||
|
||||
return (
|
||||
<time
|
||||
data-testid={testId}
|
||||
dateTime={dateObject.toISOString()}
|
||||
className={clsx(
|
||||
"reserve-one-lb",
|
||||
{
|
||||
block: !inline,
|
||||
invisible: !isLoaded,
|
||||
|
||||
@@ -13,6 +13,8 @@ interface LocaleTimeRangeProps {
|
||||
className?: string;
|
||||
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
|
||||
inline?: boolean;
|
||||
/** Optional test id forwarded to the rendered element. */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ export function LocaleTimeRange({
|
||||
options,
|
||||
className,
|
||||
inline,
|
||||
"data-testid": testId,
|
||||
}: LocaleTimeRangeProps) {
|
||||
const { formatter, isLoaded } = useDateTimeFormat(options);
|
||||
|
||||
@@ -38,8 +41,8 @@ export function LocaleTimeRange({
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={clsx(
|
||||
"reserve-one-lb",
|
||||
{
|
||||
block: !inline,
|
||||
invisible: !isLoaded,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
.input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import styles from "./RequiredHiddenInput.module.css";
|
||||
|
||||
export function RequiredHiddenInput({
|
||||
value,
|
||||
isValid,
|
||||
name,
|
||||
}: {
|
||||
value: string;
|
||||
isValid: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className={styles.input}
|
||||
name={name}
|
||||
value={isValid ? value : []}
|
||||
// empty onChange is because otherwise it will give a React error in console
|
||||
// readOnly can't be set as then validation is not active
|
||||
onChange={() => null}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
& tbody tr:hover {
|
||||
&:not(.noRowHover) tbody tr:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import clsx from "clsx";
|
||||
import styles from "./Table.module.css";
|
||||
|
||||
export function Table({ children }: { children: React.ReactNode }) {
|
||||
export function Table({
|
||||
children,
|
||||
noRowHover,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
noRowHover?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<table className={styles.table}>{children}</table>
|
||||
<table
|
||||
className={clsx(styles.table, { [styles.noRowHover]: noRowHover })}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ import clsx from "clsx";
|
||||
import { ArrowLeft, MessageSquare, X } from "lucide-react";
|
||||
import { Button } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
import { useCurrentRouteChatCode } from "~/features/chat/ChatProvider";
|
||||
import {
|
||||
extractRoomLink,
|
||||
isMatchRoomUrl,
|
||||
} from "~/features/chat/chat-constants";
|
||||
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
|
||||
import { Chat } from "~/features/chat/components/Chat";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
@@ -178,7 +174,6 @@ function ChatView({ onClose }: { onClose?: () => void }) {
|
||||
.filter(([code]) => code !== activeRoom)
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
|
||||
const roomLinkFetcher = useFetcher();
|
||||
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
|
||||
const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now());
|
||||
const messages = chatContext.messagesForRoom(activeRoom);
|
||||
@@ -194,26 +189,10 @@ function ChatView({ onClose }: { onClose?: () => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
const isMatchRoom = room?.url ? isMatchRoomUrl(room.url) : false;
|
||||
|
||||
const chatAdapter = {
|
||||
messages,
|
||||
send: (contents: string) => {
|
||||
chatContext.send(activeRoom, contents);
|
||||
|
||||
if (isMatchRoom) {
|
||||
const link = extractRoomLink(contents);
|
||||
if (link) {
|
||||
roomLinkFetcher.submit(
|
||||
{ _action: "UPSERT", url: link },
|
||||
{
|
||||
method: "post",
|
||||
action: "/room",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
currentRoom: activeRoom,
|
||||
setCurrentRoom: () => {},
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
}
|
||||
|
||||
.listBox {
|
||||
max-height: 300px;
|
||||
max-height: 325px;
|
||||
overflow-y: auto;
|
||||
padding: var(--s-2);
|
||||
outline: none;
|
||||
|
||||
@@ -21,9 +21,7 @@ import { Input } from "~/components/Input";
|
||||
import type { SearchLoaderData } from "~/features/search/routes/search";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
mySlugify,
|
||||
navIconUrl,
|
||||
teamPage,
|
||||
tournamentOrganizationPage,
|
||||
@@ -38,6 +36,7 @@ import {
|
||||
saveRecentWeapon,
|
||||
WeaponDestinationMenu,
|
||||
WeaponResultsList,
|
||||
weaponToSelectedWeapon,
|
||||
} from "./WeaponSearch";
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
@@ -166,9 +165,7 @@ function resolveInitialWeapon(
|
||||
if (Number.isNaN(id)) return null;
|
||||
const name = t(`weapons:MAIN_${id}`);
|
||||
if (!name || name === `MAIN_${id}`) return null;
|
||||
const englishName = t(`weapons:MAIN_${id}`, { lng: "en" });
|
||||
const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { lng: "en" });
|
||||
return { id, name, englishName, slug: mySlugify(slugName) };
|
||||
return weaponToSelectedWeapon(id, t);
|
||||
}
|
||||
|
||||
function GlobalSearchContent({
|
||||
@@ -239,14 +236,7 @@ function GlobalSearchContent({
|
||||
|
||||
const recentWeapons: SelectedWeapon[] =
|
||||
searchType === "weapons"
|
||||
? getRecentWeapons().map((id) => {
|
||||
const name = t(`weapons:MAIN_${id}`);
|
||||
const englishName = t(`weapons:MAIN_${id}`, { lng: "en" });
|
||||
const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, {
|
||||
lng: "en",
|
||||
});
|
||||
return { id, name, englishName, slug: mySlugify(slugName) };
|
||||
})
|
||||
? getRecentWeapons().map((id) => weaponToSelectedWeapon(id, t))
|
||||
: [];
|
||||
|
||||
const handleSelect = (key: React.Key) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Namespace, TFunction } from "i18next";
|
||||
import {
|
||||
Calculator,
|
||||
ChartColumnBig,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Flame,
|
||||
FlaskConical,
|
||||
ImageIcon,
|
||||
SlidersHorizontal,
|
||||
Users,
|
||||
Videotape,
|
||||
} from "lucide-react";
|
||||
@@ -19,6 +20,7 @@ import { filterWeapon } from "~/modules/in-game-lists/utils";
|
||||
import {
|
||||
canonicalWeaponSplId,
|
||||
mainWeaponIds,
|
||||
weaponIdToBaseWeaponId,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
ANALYZER_URL,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
weaponBuildPage,
|
||||
weaponBuildPopularPage,
|
||||
weaponBuildStatsPage,
|
||||
weaponParamsPage,
|
||||
} from "~/utils/urls";
|
||||
import styles from "./GlobalSearch.module.css";
|
||||
|
||||
@@ -37,6 +40,7 @@ const WEAPON_DESTINATIONS = [
|
||||
"popular",
|
||||
"stats",
|
||||
"analyzer",
|
||||
"params",
|
||||
"vods",
|
||||
"art",
|
||||
"lfg",
|
||||
@@ -48,6 +52,29 @@ export interface SelectedWeapon {
|
||||
name: string;
|
||||
englishName: string;
|
||||
slug: string;
|
||||
paramsSlug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link SelectedWeapon} for a main weapon id: its localized name plus the English-derived
|
||||
* url slugs (the build pages slug from the weapon's canonical id, the params page slug from its base
|
||||
* id). The caller's `t` must have the `weapons` namespace available.
|
||||
*/
|
||||
export function weaponToSelectedWeapon<Ns extends Namespace>(
|
||||
id: MainWeaponId,
|
||||
t: TFunction<Ns>,
|
||||
): SelectedWeapon {
|
||||
return {
|
||||
id,
|
||||
name: t(`weapons:MAIN_${id}` as never),
|
||||
englishName: t(`weapons:MAIN_${id}` as never, { lng: "en" }),
|
||||
slug: mySlugify(
|
||||
t(`weapons:MAIN_${canonicalWeaponSplId(id)}` as never, { lng: "en" }),
|
||||
),
|
||||
paramsSlug: mySlugify(
|
||||
t(`weapons:MAIN_${weaponIdToBaseWeaponId(id)}` as never, { lng: "en" }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterWeaponResults(
|
||||
@@ -58,24 +85,14 @@ export function filterWeaponResults(
|
||||
|
||||
const matches: SelectedWeapon[] = [];
|
||||
for (const id of mainWeaponIds) {
|
||||
const weaponName = t(`weapons:MAIN_${id}`);
|
||||
const isMatch = filterWeapon({
|
||||
weapon: { type: "MAIN", id },
|
||||
weaponName,
|
||||
weaponName: t(`weapons:MAIN_${id}`),
|
||||
searchTerm: query,
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
const englishName = t(`weapons:MAIN_${id}`, { lng: "en" });
|
||||
const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, {
|
||||
lng: "en",
|
||||
});
|
||||
matches.push({
|
||||
id,
|
||||
name: weaponName,
|
||||
englishName,
|
||||
slug: mySlugify(slugName),
|
||||
});
|
||||
matches.push(weaponToSelectedWeapon(id, t));
|
||||
}
|
||||
|
||||
if (matches.length >= 10) break;
|
||||
@@ -93,6 +110,7 @@ function getWeaponDestinationUrl(
|
||||
popular: weaponBuildPopularPage(weapon.slug),
|
||||
stats: weaponBuildStatsPage(weapon.slug),
|
||||
analyzer: `${ANALYZER_URL}?weapon=${weapon.id}`,
|
||||
params: weaponParamsPage(weapon.paramsSlug),
|
||||
vods: `${VODS_PAGE}?weapon=${weapon.id}`,
|
||||
art: `/art?tab=showcase&tag=${encodeURIComponent(weapon.englishName.toLowerCase())}`,
|
||||
lfg: `${LFG_PAGE}?q=w.${weapon.id}`,
|
||||
@@ -191,6 +209,18 @@ export function WeaponDestinationMenu({
|
||||
</span>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem
|
||||
id="params"
|
||||
href={getWeaponDestinationUrl("params", selectedWeapon)}
|
||||
className={styles.listBoxItem}
|
||||
>
|
||||
<div className={styles.resultItem}>
|
||||
<SlidersHorizontal size={20} />
|
||||
<span className={styles.resultName}>
|
||||
{t("common:pages.params")}
|
||||
</span>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem
|
||||
id="vods"
|
||||
href={getWeaponDestinationUrl("vods", selectedWeapon)}
|
||||
|
||||
@@ -18,12 +18,15 @@
|
||||
width: 100%;
|
||||
height: var(--banner-height);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-2);
|
||||
padding: var(--s-2-5);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0.2),
|
||||
rgba(0, 0, 0, 0.2),
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
),
|
||||
var(--stage-img);
|
||||
@@ -50,32 +53,39 @@
|
||||
bottom: var(--s-2);
|
||||
right: var(--s-2);
|
||||
display: flex;
|
||||
gap: var(--s-0-5);
|
||||
gap: var(--s-1);
|
||||
align-items: center;
|
||||
color: var(--color-text-high);
|
||||
background-color: var(--color-bg-high);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: normal;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.joinBadge {
|
||||
.joinInfo {
|
||||
position: absolute;
|
||||
bottom: var(--s-2);
|
||||
left: var(--s-2);
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
align-items: center;
|
||||
color: var(--color-text-high);
|
||||
background-color: var(--color-bg-high);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-2xs);
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.joinInfoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.joinInfoLabel {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.joinInfoValue {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.infoBadge {
|
||||
@@ -120,8 +130,11 @@
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0.2),
|
||||
rgba(0, 0, 0, 0.2),
|
||||
rgba(0, 0, 0, 0.6),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
),
|
||||
var(--stage-img);
|
||||
@@ -149,7 +162,8 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
gap: var(--s-0-5);
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
height: var(--banner-height);
|
||||
border-radius: var(--radius-box);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { Check, QrCode, X } from "lucide-react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
@@ -22,7 +21,7 @@ interface MatchBannerProps {
|
||||
mode: ModeShort;
|
||||
screenLegal?: boolean;
|
||||
joinPool?: string | null;
|
||||
joinViaQr?: boolean;
|
||||
joinPass?: string | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -31,7 +30,7 @@ export function MatchBanner({
|
||||
mode,
|
||||
screenLegal,
|
||||
joinPool,
|
||||
joinViaQr,
|
||||
joinPass,
|
||||
children,
|
||||
}: MatchBannerProps) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
@@ -50,7 +49,7 @@ export function MatchBanner({
|
||||
</div>
|
||||
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
|
||||
|
||||
{joinPool ? <JoinPoolBadge pool={joinPool} viaQr={joinViaQr} /> : null}
|
||||
{joinPool ? <JoinInfo pool={joinPool} pass={joinPass} /> : null}
|
||||
{screenLegal !== undefined ? (
|
||||
<ScreenNotice screenLegal={screenLegal} />
|
||||
) : null}
|
||||
@@ -82,7 +81,7 @@ interface IconBannerProps {
|
||||
subtitle?: string;
|
||||
screenLegal?: boolean;
|
||||
joinPool?: string | null;
|
||||
joinViaQr?: boolean;
|
||||
joinPass?: string | null;
|
||||
topRight?: React.ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
@@ -93,7 +92,7 @@ export function IconBanner({
|
||||
subtitle,
|
||||
screenLegal,
|
||||
joinPool,
|
||||
joinViaQr,
|
||||
joinPass,
|
||||
topRight,
|
||||
testId,
|
||||
}: IconBannerProps) {
|
||||
@@ -104,7 +103,7 @@ export function IconBanner({
|
||||
{subtitle ? (
|
||||
<div className={styles.iconBannerSubtitle}>{subtitle}</div>
|
||||
) : null}
|
||||
{joinPool ? <JoinPoolBadge pool={joinPool} viaQr={joinViaQr} /> : null}
|
||||
{joinPool ? <JoinInfo pool={joinPool} pass={joinPass} /> : null}
|
||||
{screenLegal !== undefined ? (
|
||||
<ScreenNotice screenLegal={screenLegal} />
|
||||
) : null}
|
||||
@@ -115,35 +114,33 @@ export function IconBanner({
|
||||
);
|
||||
}
|
||||
|
||||
function JoinPoolBadge({ pool, viaQr }: { pool: string; viaQr?: boolean }) {
|
||||
function JoinInfo({ pool, pass }: { pool: string; pass?: string | null }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
className={styles.joinBadge}
|
||||
onPress={() =>
|
||||
setSearchParams(
|
||||
{ tab: "join" },
|
||||
{
|
||||
preventScrollReset: true,
|
||||
defaultShouldRevalidate: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
aria-label={t("q:match.pool")}
|
||||
testId="join-pool-badge"
|
||||
>
|
||||
{viaQr ? <QrCode size={18} /> : pool}
|
||||
</SendouButton>
|
||||
<div className={styles.joinInfo}>
|
||||
<div className={styles.joinInfoItem}>
|
||||
<div className={styles.joinInfoLabel}>{t("q:match.pool")}</div>
|
||||
<div className={styles.joinInfoValue}>{pool}</div>
|
||||
</div>
|
||||
{pass ? (
|
||||
<div className={styles.joinInfoItem}>
|
||||
<div className={styles.joinInfoLabel}>
|
||||
{t("q:match.password.short")}
|
||||
</div>
|
||||
<div className={styles.joinInfoValue} data-testid="room-pass">
|
||||
{pass}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
|
||||
const { t } = useTranslation(["weapons", "q"]);
|
||||
|
||||
const imgSize = 18;
|
||||
const imgSize = 24;
|
||||
|
||||
const Icon = screenLegal ? Check : X;
|
||||
|
||||
@@ -156,16 +153,16 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
|
||||
testId={screenLegal ? "screen-allowed" : "screen-banned"}
|
||||
aria-label={screenLegal ? "Screen allowed" : "Screen banned"}
|
||||
>
|
||||
<Icon
|
||||
size={imgSize}
|
||||
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
|
||||
/>
|
||||
<img
|
||||
src={`${specialWeaponImageUrl(19)}.avif`}
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
alt=""
|
||||
/>
|
||||
<Icon
|
||||
size={imgSize}
|
||||
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
.joinContent {
|
||||
display: grid;
|
||||
grid-template-areas: "time x" "qr join";
|
||||
grid-template-columns: auto minmax(0, max-content);
|
||||
gap: var(--s-1) var(--s-4);
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.joinInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: join;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.infoHeader {
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-semi);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.infoValueTruncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.qrCodeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
grid-area: qr;
|
||||
}
|
||||
|
||||
.roomAge {
|
||||
grid-area: time;
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrCode {
|
||||
background-color: white;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.joinLink {
|
||||
font-size: var(--font-2xs);
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
text-wrap: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.qrOverlay {
|
||||
width: 172px;
|
||||
height: 172px;
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-2);
|
||||
background-color: var(--color-bg-higher);
|
||||
grid-area: qr;
|
||||
}
|
||||
|
||||
.stalePrompt {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.staleText {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noRoomHint {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
import { SendouTabPanel } from "../elements/Tabs";
|
||||
import styles from "./MatchJoinTab.module.css";
|
||||
import { TAB_KEYS } from "./MatchTabs";
|
||||
|
||||
interface MatchJoinTabProps {
|
||||
joinLink?: string;
|
||||
hostedBy?: string;
|
||||
pool: string;
|
||||
pass: string;
|
||||
showNoSplatnetAlert: boolean;
|
||||
isStale?: boolean;
|
||||
staleMinutesAgo?: number;
|
||||
refreshedAt?: Date;
|
||||
onConfirmRoom?: () => void;
|
||||
isConfirming?: boolean;
|
||||
}
|
||||
|
||||
export function MatchJoinTab({
|
||||
joinLink,
|
||||
hostedBy,
|
||||
pool,
|
||||
pass,
|
||||
showNoSplatnetAlert,
|
||||
isStale,
|
||||
staleMinutesAgo,
|
||||
refreshedAt,
|
||||
onConfirmRoom,
|
||||
isConfirming,
|
||||
}: MatchJoinTabProps) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const formatDistanceToNow = useFormatDistanceToNow();
|
||||
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.JOIN}>
|
||||
<div className="stack lg">
|
||||
{showNoSplatnetAlert ? (
|
||||
<Alert variation="WARNING" tiny>
|
||||
{t("q:match.noSplatnetWarning")}
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className={styles.joinContent}>
|
||||
{joinLink ? (
|
||||
isStale ? (
|
||||
<StaleRoomPrompt
|
||||
minutesAgo={staleMinutesAgo ?? 0}
|
||||
onConfirm={onConfirmRoom}
|
||||
isConfirming={isConfirming}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{refreshedAt ? (
|
||||
<div className={styles.roomAge}>
|
||||
{formatDistanceToNow(refreshedAt, { addSuffix: true })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.qrCodeContainer}>
|
||||
<QRCodeSVG
|
||||
value={joinLink}
|
||||
size={140}
|
||||
className={styles.qrCode}
|
||||
/>
|
||||
<a
|
||||
href={joinLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.joinLink}
|
||||
>
|
||||
{joinLink}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className={clsx(styles.qrOverlay, styles.noRoomHint)}>
|
||||
{t("q:match.room.noRoomHint")}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.joinInfo}>
|
||||
{hostedBy ? (
|
||||
<InfoWithHeader
|
||||
header={t("q:match.hostedBy")}
|
||||
value={hostedBy}
|
||||
truncate
|
||||
/>
|
||||
) : null}
|
||||
<InfoWithHeader header={t("q:match.pool")} value={pool} />
|
||||
<InfoWithHeader
|
||||
header={t("q:match.password.short")}
|
||||
value={pass}
|
||||
testId="room-pass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleRoomPrompt({
|
||||
minutesAgo,
|
||||
onConfirm,
|
||||
isConfirming,
|
||||
}: {
|
||||
minutesAgo: number;
|
||||
onConfirm?: () => void;
|
||||
isConfirming?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.qrOverlay, styles.stalePrompt)}>
|
||||
<div className={styles.staleText}>
|
||||
{t("q:match.room.stalePrompt", { minutes: minutesAgo })}
|
||||
</div>
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onPress={onConfirm}
|
||||
isDisabled={isConfirming}
|
||||
>
|
||||
{t("q:match.room.confirm")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoWithHeader({
|
||||
header,
|
||||
value,
|
||||
testId,
|
||||
truncate,
|
||||
}: {
|
||||
header: string;
|
||||
value: string;
|
||||
testId?: string;
|
||||
truncate?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.infoHeader}>{header}</div>
|
||||
<div
|
||||
className={clsx(styles.infoValue, {
|
||||
[styles.infoValueTruncate]: truncate,
|
||||
})}
|
||||
data-testid={testId}
|
||||
title={truncate ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,23 +104,22 @@
|
||||
}
|
||||
|
||||
.memberGrid {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"avatar name"
|
||||
"tier meta";
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: var(--s-2);
|
||||
row-gap: var(--s-1);
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.memberLink {
|
||||
grid-row: 1;
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
column-gap: var(--s-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.memberSecondRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.memberNameStack {
|
||||
@@ -143,7 +142,6 @@
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.friendCodeHeader {
|
||||
@@ -168,12 +166,10 @@
|
||||
}
|
||||
|
||||
.memberTier {
|
||||
grid-area: tier;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.memberMetaArea {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memberMeta {
|
||||
|
||||
@@ -50,6 +50,8 @@ interface RosterTabTeam {
|
||||
subbedOut?: Array<number>;
|
||||
tier?: { name: TierName; isPlus: boolean };
|
||||
seed?: number | null;
|
||||
/** Whether this team is expected to host the room (tournament only). */
|
||||
isHost?: boolean;
|
||||
}
|
||||
|
||||
interface MatchRosterTabProps {
|
||||
@@ -162,10 +164,10 @@ function TeamRoster({
|
||||
member={member}
|
||||
className={styles.memberLink}
|
||||
/>
|
||||
<div className={styles.memberTier}>
|
||||
<MemberTierPopover tier={member.tier} />
|
||||
</div>
|
||||
<div className={styles.memberMetaArea}>
|
||||
<div className={styles.memberSecondRow}>
|
||||
<div className={styles.memberTier}>
|
||||
<MemberTierPopover tier={member.tier} />
|
||||
</div>
|
||||
<MemberMeta
|
||||
plusTier={member.plusTier}
|
||||
weaponPool={member.weaponPool}
|
||||
@@ -261,6 +263,8 @@ function TeamHeader({
|
||||
label: string;
|
||||
dotClassName: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const tierText = team.tier
|
||||
? `${team.tier.name.toLowerCase()}${team.tier.isPlus ? "+" : ""}`
|
||||
: undefined;
|
||||
@@ -282,6 +286,12 @@ function TeamHeader({
|
||||
<span>{seedText}</span>
|
||||
</>
|
||||
) : null}
|
||||
{team.isHost ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{t("common:host")}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import {
|
||||
BarChart3,
|
||||
DoorOpen,
|
||||
Key,
|
||||
ScrollText,
|
||||
Tally5,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { BarChart3, Key, ScrollText, Tally5, Users } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { SendouTab, SendouTabList, SendouTabs } from "../elements/Tabs";
|
||||
import styles from "./MatchTabs.module.css";
|
||||
@@ -27,7 +19,6 @@ const TAB_KEY = "tab";
|
||||
export const TAB_KEYS = {
|
||||
ROSTERS: "rosters",
|
||||
ACTION: "action",
|
||||
JOIN: "join",
|
||||
RESULT: "result",
|
||||
STATS: "stats",
|
||||
ADMIN: "admin",
|
||||
@@ -36,7 +27,6 @@ export const TAB_KEYS = {
|
||||
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
|
||||
rosters: <Users />,
|
||||
action: <Tally5 />,
|
||||
join: <DoorOpen />,
|
||||
result: <ScrollText />,
|
||||
stats: <BarChart3 />,
|
||||
admin: <Key />,
|
||||
@@ -45,7 +35,6 @@ const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
|
||||
const TAB_TRANSLATION_KEYS = {
|
||||
rosters: "q:match.tabs.rosters",
|
||||
action: "q:match.tabs.action",
|
||||
join: "common:actions.join",
|
||||
result: "q:match.tabs.result",
|
||||
stats: "q:match.tabs.stats",
|
||||
admin: "common:pages.admin",
|
||||
@@ -54,12 +43,9 @@ const TAB_TRANSLATION_KEYS = {
|
||||
export function MatchTabs({ children, tabs, alertTabs }: MatchTabsProps) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const user = useUser();
|
||||
|
||||
const preferredTab = user?.preferences.defaultMatchPageTab;
|
||||
const currentTab =
|
||||
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ??
|
||||
(preferredTab && tabs.includes(preferredTab) ? preferredTab : tabs.at(0));
|
||||
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ?? tabs.at(0);
|
||||
invariant(currentTab);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2726,23 +2726,6 @@ async function groups(variation?: SeedVariation | null) {
|
||||
expiresAfter: { hours: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
const thirtyMinutesAgo = dateToDatabaseTimestamp(
|
||||
sub(new Date(), { minutes: 30 }),
|
||||
);
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `
|
||||
insert into "RoomLink" ("userId", "url", "createdAt", "refreshedAt")
|
||||
values (@userId, @url, @createdAt, @refreshedAt)
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
userId: ADMIN_ID,
|
||||
url: "https://example.com//private_battle/seed_room_123",
|
||||
createdAt: thirtyMinutesAgo,
|
||||
refreshedAt: thirtyMinutesAgo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1070,8 +1070,6 @@ export interface UserPreferences {
|
||||
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
|
||||
spoilerFreeMode?: boolean;
|
||||
weaponReportDefaultOpen?: boolean;
|
||||
/** Which tab opens first on the match page. Unset = first available tab (Rosters). */
|
||||
defaultMatchPageTab?: "rosters" | "join" | "action";
|
||||
}
|
||||
|
||||
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
|
||||
@@ -1133,8 +1131,6 @@ export interface User {
|
||||
weaponPool: JSONColumnTypeNullable<WeaponPoolEntry[]>;
|
||||
plusSkippedForSeasonNth: number | null;
|
||||
noScreen: Generated<DBBoolean>;
|
||||
/** User doesn't have access to SplatNet 3 to join rooms made by others */
|
||||
noSplatnet: Generated<DBBoolean>;
|
||||
buildSorting: JSONColumnTypeNullable<BuildSort[]>;
|
||||
preferences: JSONColumnTypeNullable<UserPreferences>;
|
||||
/** User creation date. Can be null because we did not always save this. */
|
||||
@@ -1435,13 +1431,6 @@ export interface NotificationUserSubscription {
|
||||
subscription: JSONColumnType<NotificationSubscription>;
|
||||
}
|
||||
|
||||
export interface RoomLink {
|
||||
userId: number;
|
||||
url: string;
|
||||
createdAt: Generated<number>;
|
||||
refreshedAt: Generated<number>;
|
||||
}
|
||||
|
||||
export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
|
||||
export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number];
|
||||
|
||||
@@ -1500,7 +1489,6 @@ export interface DB {
|
||||
PlusTier: PlusTier;
|
||||
PlusVote: PlusVote;
|
||||
PlusVotingResult: PlusVotingResult;
|
||||
RoomLink: RoomLink;
|
||||
ReportedWeapon: ReportedWeapon;
|
||||
Skill: Skill;
|
||||
SkillTeamUser: SkillTeamUser;
|
||||
|
||||
@@ -895,6 +895,10 @@
|
||||
"displayName": "Weapon Lockdown",
|
||||
"authorDiscordId": "338806780446638082"
|
||||
},
|
||||
"okitty": {
|
||||
"displayName": "Orange Kitty",
|
||||
"authorDiscordId": "752582395076673577"
|
||||
},
|
||||
"oktofest-bronze": {
|
||||
"displayName": "Oktofest LAN (Third)",
|
||||
"authorDiscordId": "751912670403362836"
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { DAMAGE_TYPE } from "./analyzer-constants";
|
||||
import type { SPECIAL_EFFECTS } from "./core/specialEffects";
|
||||
import type { weaponParams } from "./core/weapon-params";
|
||||
import type { weaponParams } from "./data/weapon-params";
|
||||
|
||||
type Overwrites = Record<
|
||||
string,
|
||||
|
||||
@@ -40,7 +40,7 @@ import type {
|
||||
SubWeaponParams,
|
||||
} from "../analyzer-types";
|
||||
import { INK_CONSUME_TYPES } from "../analyzer-types";
|
||||
import type { abilityValues as abilityValuesJson } from "./ability-values";
|
||||
import type { abilityValues as abilityValuesJson } from "../data/ability-values";
|
||||
import {
|
||||
abilityPointsToEffects,
|
||||
abilityValues,
|
||||
|
||||
@@ -35,8 +35,8 @@ import type {
|
||||
SubWeaponDamage,
|
||||
SubWeaponParams,
|
||||
} from "../analyzer-types";
|
||||
import { abilityValues as abilityValuesJson } from "./ability-values";
|
||||
import { weaponParams as rawWeaponParams } from "./weapon-params";
|
||||
import { abilityValues as abilityValuesJson } from "../data/ability-values";
|
||||
import { weaponParams as rawWeaponParams } from "../data/weapon-params";
|
||||
|
||||
export function weaponParams(): ParamsJson {
|
||||
return rawWeaponParams as unknown as ParamsJson;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { FlaskConical, SlidersHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction, ShouldRevalidateFunction } from "react-router";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { Image } from "~/components/Image";
|
||||
import { weaponToSelectedWeapon } from "~/components/layout/WeaponSearch";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { Table } from "~/components/Table";
|
||||
@@ -52,8 +53,9 @@ import {
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
userNewBuildPage,
|
||||
weaponParamsPage,
|
||||
} from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { LinkButton, SendouButton } from "../../../components/elements/Button";
|
||||
import { SendouPopover } from "../../../components/elements/Popover";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import {
|
||||
@@ -246,7 +248,7 @@ function BuildAnalyzerPage() {
|
||||
<Main>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<div className="stack sm items-center w-full">
|
||||
<div className="stack sm items-start w-full">
|
||||
<div className="w-full">
|
||||
<WeaponSelect
|
||||
label={t("analyzer:weaponSelect.label")}
|
||||
@@ -258,6 +260,16 @@ function BuildAnalyzerPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<LinkButton
|
||||
to={weaponParamsPage(
|
||||
weaponToSelectedWeapon(mainWeaponId, t).paramsSlug,
|
||||
)}
|
||||
variant="minimal"
|
||||
size="small"
|
||||
icon={<SlidersHorizontal />}
|
||||
>
|
||||
{t("analyzer:rawParameters")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="stack md items-center w-full">
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
|
||||
|
||||
export const MAX_BUILD_FILTERS = 6;
|
||||
@@ -6,73 +7,59 @@ export const FILTER_SEARCH_PARAM_KEY = "f";
|
||||
|
||||
type Patch = { patch: string; date: string };
|
||||
|
||||
/**
|
||||
* Every Splatoon 3 game version that introduced weapon parameter changes, newest first,
|
||||
* with its release date (`YYYY-MM-DD`). The version strings match those tracked in the
|
||||
* weapon params data (`metadata.versions`), so this is the single source of patch dates
|
||||
* used by both the builds date filter and the weapon params patch history.
|
||||
*/
|
||||
export const PATCHES: Array<Patch> = [
|
||||
{
|
||||
patch: "11.2.0",
|
||||
date: "2026-06-10",
|
||||
},
|
||||
{
|
||||
patch: "11.1.0",
|
||||
date: "2026-03-18",
|
||||
},
|
||||
{
|
||||
patch: "11.0.0",
|
||||
date: "2026-01-29",
|
||||
},
|
||||
// {
|
||||
// patch: "10.1.0",
|
||||
// date: "2025-09-03",
|
||||
// },
|
||||
// {
|
||||
// patch: "10.0.0",
|
||||
// date: "2025-06-12",
|
||||
// },
|
||||
// {
|
||||
// patch: "9.3.0",
|
||||
// date: "2025-03-13",
|
||||
// },
|
||||
// {
|
||||
// patch: "9.2.0",
|
||||
// date: "2024-11-20",
|
||||
// },
|
||||
// {
|
||||
// patch: "9.0.0",
|
||||
// date: "2024-08-29",
|
||||
// },
|
||||
// {
|
||||
// patch: "8.1.0",
|
||||
// date: "2024-07-17",
|
||||
// },
|
||||
// {
|
||||
// patch: "8.0.0",
|
||||
// date: "2024-05-31",
|
||||
// },
|
||||
// {
|
||||
// patch: "7.2.0",
|
||||
// date: "2024-04-17",
|
||||
// },
|
||||
// {
|
||||
// patch: "7.0.0",
|
||||
// date: "2024-02-21",
|
||||
// },
|
||||
// {
|
||||
// patch: "6.1.0",
|
||||
// date: "2024-01-24",
|
||||
// },
|
||||
// {
|
||||
// patch: "6.0.0",
|
||||
// date: "2023-11-29",
|
||||
// },
|
||||
// {
|
||||
// patch: "5.1.0",
|
||||
// date: "2023-10-17",
|
||||
// },
|
||||
// {
|
||||
// patch: "5.0.0",
|
||||
// date: "2023-08-30",
|
||||
// },
|
||||
{ patch: "11.2.0", date: "2026-06-10" },
|
||||
{ patch: "11.1.0", date: "2026-03-18" },
|
||||
{ patch: "11.0.1", date: "2026-02-10" },
|
||||
{ patch: "11.0.0", date: "2026-01-28" },
|
||||
{ patch: "10.1.0", date: "2025-09-03" },
|
||||
{ patch: "10.0.0", date: "2025-06-11" },
|
||||
{ patch: "9.3.0", date: "2025-03-12" },
|
||||
{ patch: "9.2.0", date: "2024-11-20" },
|
||||
{ patch: "9.1.0", date: "2024-09-11" },
|
||||
{ patch: "9.0.0", date: "2024-08-29" },
|
||||
{ patch: "8.1.0", date: "2024-07-17" },
|
||||
{ patch: "8.0.0", date: "2024-05-30" },
|
||||
{ patch: "7.2.0", date: "2024-04-17" },
|
||||
{ patch: "7.1.0", date: "2024-03-21" },
|
||||
{ patch: "7.0.0", date: "2024-02-21" },
|
||||
{ patch: "6.1.0", date: "2024-01-24" },
|
||||
{ patch: "6.0.0", date: "2023-11-29" },
|
||||
{ patch: "5.2.0", date: "2023-10-17" },
|
||||
{ patch: "5.1.0", date: "2023-09-13" },
|
||||
{ patch: "5.0.0", date: "2023-08-30" },
|
||||
{ patch: "4.1.0", date: "2023-07-26" },
|
||||
{ patch: "4.0.0", date: "2023-05-31" },
|
||||
{ patch: "3.1.0", date: "2023-03-07" },
|
||||
{ patch: "3.0.0", date: "2023-02-28" },
|
||||
{ patch: "2.1.0", date: "2022-12-06" },
|
||||
{ patch: "2.0.0", date: "2022-11-30" },
|
||||
{ patch: "1.2.0", date: "2022-11-16" },
|
||||
{ patch: "1.1.1", date: "2022-09-20" },
|
||||
{ patch: "1.1.0", date: "2022-09-08" },
|
||||
{ patch: "1.0.0", date: "2022-09-09" },
|
||||
{ patch: "0.9.9", date: "2022-09-09" },
|
||||
];
|
||||
|
||||
const RECENT_PATCH_MAX_AGE_IN_DAYS = 365;
|
||||
|
||||
/**
|
||||
* The subset of {@link PATCHES} released within roughly a year of the newest patch. Used
|
||||
* for the builds date filter so its dropdown stays short while always covering the patches
|
||||
* builds are most likely to be filtered against.
|
||||
*/
|
||||
export const RECENT_PATCHES: Array<Patch> = PATCHES.filter(
|
||||
({ date }) =>
|
||||
differenceInCalendarDays(new Date(PATCHES[0].date), new Date(date)) <=
|
||||
RECENT_PATCH_MAX_AGE_IN_DAYS,
|
||||
);
|
||||
|
||||
export const BUILD = {
|
||||
MAX_COUNT: 250,
|
||||
} as const;
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { dateToYYYYMMDD, isValidDate } from "~/utils/dates";
|
||||
import { PATCHES } from "../builds-constants";
|
||||
import { RECENT_PATCHES } from "../builds-constants";
|
||||
import type {
|
||||
AbilityBuildFilter,
|
||||
BuildFilter,
|
||||
@@ -203,7 +203,9 @@ function DateFilter({
|
||||
});
|
||||
|
||||
const selectValue = () =>
|
||||
PATCHES.some(({ date }) => date === filter.date) ? filter.date : "CUSTOM";
|
||||
RECENT_PATCHES.some(({ date }) => date === filter.date)
|
||||
? filter.date
|
||||
: "CUSTOM";
|
||||
|
||||
// on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays)
|
||||
const oneMonthAgoOnSaturday = new Date();
|
||||
@@ -232,7 +234,7 @@ function DateFilter({
|
||||
})
|
||||
}
|
||||
>
|
||||
{PATCHES.map(({ patch, date: dateString }) => {
|
||||
{RECENT_PATCHES.map(({ patch, date: dateString }) => {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
BUILDS_PAGE_MAX_BUILDS,
|
||||
FILTER_SEARCH_PARAM_KEY,
|
||||
MAX_BUILD_FILTERS,
|
||||
PATCHES,
|
||||
RECENT_PATCHES,
|
||||
} from "../builds-constants";
|
||||
import {
|
||||
type BuildFiltersFromSearchParams,
|
||||
@@ -210,7 +210,7 @@ export default function WeaponsBuildsPage() {
|
||||
: type === "date"
|
||||
? {
|
||||
type: "date",
|
||||
date: PATCHES[0].date,
|
||||
date: RECENT_PATCHES[0].date,
|
||||
}
|
||||
: {
|
||||
type: "mode",
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { CalendarEventTag } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
@@ -22,33 +22,36 @@ import {
|
||||
badRequestIfFalsy,
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseFormData,
|
||||
uploadImageIfSubmitted,
|
||||
} from "~/utils/remix.server";
|
||||
import { pathnameFromPotentialURL } from "~/utils/strings";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import { CALENDAR_EVENT } from "../calendar-constants";
|
||||
import { calendarNewSchemaServer } from "../calendar-new-schemas.server";
|
||||
import { canEditCalendarEvent, regClosesAtDate } from "../calendar-utils";
|
||||
import { findValidOrganizations } from "../loaders/calendar.new.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
|
||||
const { avatarFileName, formData } = await uploadImageIfSubmitted({
|
||||
const result = await parseFormDataWithImages({
|
||||
request,
|
||||
fileNamePrefix: "tournament-logo",
|
||||
});
|
||||
const data = await parseFormData({
|
||||
formData,
|
||||
schema: newCalendarEventActionSchema,
|
||||
schema: calendarNewSchemaServer,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
const isEditing = Boolean(data.eventToEditId);
|
||||
const isAddingTournament = data.toToolsEnabled;
|
||||
const organizationId = data.organizationId
|
||||
? Number(data.organizationId)
|
||||
: null;
|
||||
|
||||
if (data.organizationId) {
|
||||
if (organizationId) {
|
||||
await validateOrganization({
|
||||
userId: user.id,
|
||||
organizationId: data.organizationId,
|
||||
organizationId,
|
||||
isTournamentAdder: user.roles.includes("TOURNAMENT_ADDER"),
|
||||
});
|
||||
} else if (!isEditing) {
|
||||
@@ -59,69 +62,70 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
const managedBadges = await BadgeRepository.findManagedByUserId(user.id);
|
||||
|
||||
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
|
||||
const dates =
|
||||
isAddingTournament && data.startTime ? [data.startTime] : data.date;
|
||||
const startTimes = dates.map((date) => dateToDatabaseTimestamp(date));
|
||||
const commonArgs = {
|
||||
authorId: user.id,
|
||||
organizationId: data.organizationId ?? null,
|
||||
organizationId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
rules: data.rules,
|
||||
startTimes,
|
||||
bracketUrl: data.bracketUrl,
|
||||
discordInviteCode: data.discordInviteCode,
|
||||
tags: data.tags
|
||||
? data.tags
|
||||
.sort(
|
||||
(a, b) =>
|
||||
CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) -
|
||||
CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag),
|
||||
)
|
||||
.join(",")
|
||||
: data.tags,
|
||||
badges:
|
||||
data.badges?.filter((badge) =>
|
||||
managedBadges.some((mb) => mb.id === badge),
|
||||
) ?? [],
|
||||
// newly uploaded avatar
|
||||
avatarFileName,
|
||||
// reused avatar either via edit or template
|
||||
bracketUrl: data.bracketUrl || "https://sendou.ink",
|
||||
discordInviteCode: data.discordInviteCode
|
||||
? pathnameFromPotentialURL(data.discordInviteCode)
|
||||
: data.discordInviteCode,
|
||||
tags:
|
||||
data.tags.length > 0
|
||||
? data.tags
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) -
|
||||
CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag),
|
||||
)
|
||||
.join(",")
|
||||
: null,
|
||||
badges: data.badges.filter((badge) =>
|
||||
managedBadges.some((mb) => mb.id === badge),
|
||||
),
|
||||
// resolved by parseFormDataWithImages from the `image()` field
|
||||
avatarImgId: data.avatarImgId ?? undefined,
|
||||
autoValidateAvatar: user.roles.includes("SUPPORTER"),
|
||||
toToolsEnabled: Number(data.toToolsEnabled),
|
||||
toToolsMode:
|
||||
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
|
||||
bracketProgression: data.bracketProgression ?? null,
|
||||
minMembersPerTeam: data.minMembersPerTeam ?? undefined,
|
||||
maxMembersPerTeam: data.maxMembersPerTeam ?? undefined,
|
||||
isRanked: data.isRanked ?? undefined,
|
||||
isTest: data.isTest ?? undefined,
|
||||
isDraft: data.isDraft ?? undefined,
|
||||
isInvitational: data.isInvitational ?? false,
|
||||
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
|
||||
enableSubs: data.enableSubs ?? undefined,
|
||||
requireInGameNames: data.requireInGameNames ?? undefined,
|
||||
requireSendouQParticipation: data.requireSendouQParticipation ?? undefined,
|
||||
autonomousSubs: data.autonomousSubs ?? undefined,
|
||||
minMembersPerTeam: Number(data.minMembersPerTeam),
|
||||
maxMembersPerTeam:
|
||||
data.minMembersPerTeam === "4" && data.maxMembersPerTeam
|
||||
? data.maxMembersPerTeam
|
||||
: undefined,
|
||||
isRanked: data.isRanked,
|
||||
isTest: data.isTest,
|
||||
isDraft: data.isDraft,
|
||||
isInvitational: data.isInvitational,
|
||||
enableNoScreenToggle: data.enableNoScreenToggle,
|
||||
enableSubs: data.enableSubs,
|
||||
requireInGameNames: data.requireInGameNames,
|
||||
requireSendouQParticipation: data.requireSendouQParticipation,
|
||||
autonomousSubs: data.autonomousSubs,
|
||||
tournamentToCopyId: data.tournamentToCopyId,
|
||||
regClosesAt: data.regClosesAt
|
||||
? dateToDatabaseTimestamp(
|
||||
regClosesAtDate({
|
||||
startTime: databaseTimestampToDate(startTimes[0]),
|
||||
closesAt: data.regClosesAt,
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
regClosesAt:
|
||||
isAddingTournament && data.regClosesAt
|
||||
? dateToDatabaseTimestamp(
|
||||
regClosesAtDate({
|
||||
startTime: databaseTimestampToDate(startTimes[0]),
|
||||
closesAt: data.regClosesAt,
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
errorToastIfFalsy(
|
||||
!commonArgs.toToolsEnabled || commonArgs.bracketProgression,
|
||||
"Bracket progression must be set for tournaments",
|
||||
);
|
||||
|
||||
const deserializedMaps = (() => {
|
||||
if (!data.pool) return;
|
||||
|
||||
return MapPool.toDbList(data.pool);
|
||||
})();
|
||||
const deserializedMaps = data.pool ? MapPool.toDbList(data.pool) : undefined;
|
||||
|
||||
if (data.eventToEditId) {
|
||||
const eventToEdit = badRequestIfFalsy(
|
||||
|
||||
@@ -64,7 +64,6 @@ export const CALENDAR_EVENT = {
|
||||
MAX_AMOUNT_OF_DATES: 5,
|
||||
/** Calendar event tag that is persisted in the database */
|
||||
TAGS: Object.keys(tags) as Array<CalendarEventTag>,
|
||||
AVATAR_SIZE: 512,
|
||||
};
|
||||
|
||||
export const REG_CLOSES_AT_OPTIONS = [
|
||||
|
||||
8
app/features/calendar/calendar-new-schemas.server.ts
Normal file
8
app/features/calendar/calendar-new-schemas.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
calendarNewBaseSchema,
|
||||
calendarNewSyncRefine,
|
||||
} from "./calendar-new-schemas";
|
||||
|
||||
export const calendarNewSchemaServer = calendarNewBaseSchema.superRefine(
|
||||
calendarNewSyncRefine,
|
||||
);
|
||||
226
app/features/calendar/calendar-new-schemas.ts
Normal file
226
app/features/calendar/calendar-new-schemas.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { z } from "zod";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
array,
|
||||
badges,
|
||||
checkboxGroup,
|
||||
customField,
|
||||
datetimeOptional,
|
||||
datetimeRequired,
|
||||
idConstantOptional,
|
||||
image,
|
||||
numberFieldOptional,
|
||||
select,
|
||||
selectDynamicOptional,
|
||||
textAreaOptional,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
|
||||
import { bracketProgressionSchema } from "./calendar-schemas";
|
||||
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
|
||||
|
||||
/** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */
|
||||
const calendarEventDateField = datetimeRequired({
|
||||
label: "labels.date",
|
||||
min: calendarEventMinDate,
|
||||
max: calendarEventMaxDate,
|
||||
});
|
||||
|
||||
export const calendarNewBaseSchema = z.object({
|
||||
// discriminates between a calendar event and a tournament; seeded from the loader, no visible control
|
||||
toToolsEnabled: customField({ initialValue: false }, z.boolean()), // xxx: use "stringConstant" instead
|
||||
eventToEditId: idConstantOptional(),
|
||||
tournamentToCopyId: idConstantOptional(),
|
||||
name: textFieldRequired({
|
||||
label: "labels.name",
|
||||
minLength: CALENDAR_EVENT.NAME_MIN_LENGTH,
|
||||
maxLength: CALENDAR_EVENT.NAME_MAX_LENGTH,
|
||||
}),
|
||||
description: textAreaOptional({
|
||||
label: "labels.description",
|
||||
maxLength: CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
organizationId: selectDynamicOptional({ label: "labels.organization" }),
|
||||
rules: textAreaOptional({
|
||||
label: "labels.rules",
|
||||
bottomText: "bottomTexts.bioMarkdown",
|
||||
maxLength: CALENDAR_EVENT.RULES_MAX_LENGTH,
|
||||
}),
|
||||
// calendar events can span multiple dates; tournaments always have exactly one
|
||||
// (`startTime`). Only the relevant field is rendered, and the other stays at its
|
||||
// empty initial value — `calendarNewSyncRefine` enforces the right one per type.
|
||||
date: array({
|
||||
label: "labels.dates",
|
||||
max: CALENDAR_EVENT.MAX_AMOUNT_OF_DATES,
|
||||
field: calendarEventDateField,
|
||||
}),
|
||||
startTime: datetimeOptional({
|
||||
label: "labels.date",
|
||||
bottomText: "bottomTexts.tournamentStartTime",
|
||||
min: calendarEventMinDate,
|
||||
max: calendarEventMaxDate,
|
||||
}),
|
||||
bracketUrl: textFieldOptional({
|
||||
label: "labels.bracketUrl",
|
||||
maxLength: CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH,
|
||||
validate: "url",
|
||||
}),
|
||||
discordInviteCode: textFieldOptional({
|
||||
label: "labels.discordInvite",
|
||||
maxLength: CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH,
|
||||
leftAddon: "https://discord.gg/",
|
||||
}),
|
||||
tags: checkboxGroup({
|
||||
label: "labels.tags",
|
||||
items: CALENDAR_EVENT.TAGS.map((tag) => ({
|
||||
value: tag,
|
||||
label: `options.tag.${tag}` as const,
|
||||
})),
|
||||
}),
|
||||
badges: badges({ label: "labels.badges", maxCount: 50 }),
|
||||
avatarImgId: image({
|
||||
label: "labels.logo",
|
||||
bottomText: "bottomTexts.avatarValidation",
|
||||
autoValidate: true,
|
||||
}),
|
||||
regClosesAt: select({
|
||||
label: "labels.regClosesAt",
|
||||
bottomText: "bottomTexts.regClosesAt",
|
||||
items: REG_CLOSES_AT_OPTIONS.map((option) => ({
|
||||
value: option,
|
||||
label: `options.regClosesAt.${option}` as const,
|
||||
})),
|
||||
}),
|
||||
minMembersPerTeam: select({
|
||||
label: "labels.playersCount",
|
||||
items: [4, 3, 2, 1].map((count) => ({
|
||||
value: String(count),
|
||||
label: () => `${count}v${count}`,
|
||||
})),
|
||||
}),
|
||||
maxMembersPerTeam: numberFieldOptional({
|
||||
label: "labels.maxTeamSize",
|
||||
bottomText: "bottomTexts.maxTeamSize",
|
||||
}),
|
||||
toToolsMode: select({
|
||||
label: "labels.mapPickingStyle",
|
||||
items: [
|
||||
{ value: "ALL", label: "options.toToolsMode.ALL" },
|
||||
{ value: "SZ", label: "options.toToolsMode.SZ" },
|
||||
{ value: "TC", label: "options.toToolsMode.TC" },
|
||||
{ value: "RM", label: "options.toToolsMode.RM" },
|
||||
{ value: "CB", label: "options.toToolsMode.CB" },
|
||||
{ value: "TO", label: "options.toToolsMode.TO" },
|
||||
],
|
||||
}),
|
||||
pool: customField({ initialValue: "" }, z.string().optional()),
|
||||
bracketProgression: customField(
|
||||
{ initialValue: null },
|
||||
bracketProgressionSchema.nullish(),
|
||||
),
|
||||
isRanked: toggle({
|
||||
label: "labels.ranked",
|
||||
bottomText: "bottomTexts.ranked",
|
||||
}),
|
||||
enableNoScreenToggle: toggle({
|
||||
label: "labels.splattercolorScreenToggle",
|
||||
bottomText: "bottomTexts.splattercolorScreen",
|
||||
}),
|
||||
enableSubs: toggle({
|
||||
label: "labels.lfgTab",
|
||||
bottomText: "bottomTexts.lfgTab",
|
||||
}),
|
||||
autonomousSubs: toggle({
|
||||
label: "labels.autonomousSubs",
|
||||
bottomText: "bottomTexts.autonomousSubs",
|
||||
}),
|
||||
requireInGameNames: toggle({
|
||||
label: "labels.requireInGameNames",
|
||||
bottomText: "bottomTexts.requireInGameNames",
|
||||
}),
|
||||
isInvitational: toggle({
|
||||
label: "labels.invitational",
|
||||
bottomText: "bottomTexts.invitational",
|
||||
}),
|
||||
isTest: toggle({ label: "labels.test", bottomText: "bottomTexts.test" }),
|
||||
isDraft: toggle({
|
||||
label: "labels.draft",
|
||||
bottomText: "bottomTexts.draftInfo",
|
||||
}),
|
||||
requireSendouQParticipation: toggle({
|
||||
label: "labels.requireSendouQ",
|
||||
bottomText: "bottomTexts.requireSendouQ",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Shared sync cross-field rules, reused by the server schema (see `*.server.ts`). */
|
||||
export function calendarNewSyncRefine(
|
||||
data: z.infer<typeof calendarNewBaseSchema>,
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
// a calendar event needs at least one date; a tournament needs its single start time
|
||||
if (!data.toToolsEnabled && data.date.length < 1) {
|
||||
ctx.addIssue({
|
||||
path: ["date"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.toToolsEnabled && !data.startTime) {
|
||||
ctx.addIssue({
|
||||
path: ["startTime"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
|
||||
// a calendar event needs a bracket URL; tournaments default to sendou.ink in the action
|
||||
if (!data.toToolsEnabled && !data.bracketUrl) {
|
||||
ctx.addIssue({
|
||||
path: ["bracketUrl"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.bracketUrlRequired",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.toToolsEnabled && !data.bracketProgression) {
|
||||
ctx.addIssue({
|
||||
path: ["bracketProgression"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.bracketProgressionRequired",
|
||||
});
|
||||
}
|
||||
|
||||
// "Prepicked by teams - All modes" requires one tiebreaker map per ranked mode
|
||||
if (data.toToolsEnabled && data.toToolsMode === "ALL") {
|
||||
const maps = data.pool ? MapPool.toDbList(data.pool) : [];
|
||||
const isValid =
|
||||
maps.length === rankedModesShort.length &&
|
||||
rankedModesShort.every((mode) => maps.some((map) => map.mode === mode));
|
||||
|
||||
if (!isValid) {
|
||||
ctx.addIssue({
|
||||
path: ["pool"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.allModePool",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.toToolsEnabled &&
|
||||
data.minMembersPerTeam === "4" &&
|
||||
data.maxMembersPerTeam &&
|
||||
(data.maxMembersPerTeam < 4 || data.maxMembersPerTeam > 10)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
path: ["maxMembersPerTeam"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.maxMembersRange",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.badges {
|
||||
width: max-content;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-badge);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
:global(html.light) .badges {
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.avatarPreview {
|
||||
width: 124px;
|
||||
height: 124px;
|
||||
border-radius: var(--radius-avatar);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import {
|
||||
bracketProgressionSchema,
|
||||
calendarEventTagSchema,
|
||||
} from "~/features/calendar/calendar-schemas";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import {
|
||||
actualNumber,
|
||||
checkboxValueToBoolean,
|
||||
date,
|
||||
falsyToNull,
|
||||
id,
|
||||
processMany,
|
||||
removeDuplicates,
|
||||
safeJSONParse,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
|
||||
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
|
||||
|
||||
export const newCalendarEventActionSchema = z
|
||||
.object({
|
||||
eventToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
|
||||
organizationId: z.preprocess(actualNumber, id.nullish()),
|
||||
name: z
|
||||
.string()
|
||||
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
|
||||
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
|
||||
description: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
|
||||
),
|
||||
rules: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
|
||||
),
|
||||
date: z.preprocess(
|
||||
toArray,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
date,
|
||||
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
|
||||
),
|
||||
)
|
||||
.min(1)
|
||||
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
|
||||
),
|
||||
bracketUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
|
||||
.default("https://sendou.ink"),
|
||||
discordInviteCode: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
tags: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(calendarEventTagSchema).nullable(),
|
||||
),
|
||||
badges: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(id).nullable(),
|
||||
),
|
||||
avatarImgId: id.nullish(),
|
||||
pool: z.string().optional(),
|
||||
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
|
||||
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isDraft: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
|
||||
enableNoScreenToggle: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
requireInGameNames: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
requireSendouQParticipation: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
minMembersPerTeam: z.preprocess(
|
||||
actualNumber,
|
||||
z.number().int().min(1).max(4).nullish(),
|
||||
),
|
||||
maxMembersPerTeam: z.preprocess(
|
||||
actualNumber,
|
||||
z.number().int().min(4).max(10).nullish(),
|
||||
),
|
||||
bracketProgression: bracketProgressionSchema.nullish(),
|
||||
})
|
||||
.refine(
|
||||
async (schema) => {
|
||||
if (schema.eventToEditId) {
|
||||
const eventToEdit = await CalendarRepository.findById(
|
||||
schema.eventToEditId,
|
||||
);
|
||||
return schema.date.length === 1 || !eventToEdit?.tournamentId;
|
||||
}
|
||||
return schema.date.length === 1 || !schema.toToolsEnabled;
|
||||
},
|
||||
{
|
||||
message: "Tournament must have exactly one date",
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.toToolsMode !== "ALL") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maps = schema.pool ? MapPool.toDbList(schema.pool) : [];
|
||||
|
||||
return (
|
||||
maps.length === 4 &&
|
||||
rankedModesShort.every((mode) => maps.some((map) => map.mode === mode))
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"',
|
||||
},
|
||||
);
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { CALENDAR_EVENT, CALENDAR_EVENT_RESULT } from "./calendar-constants";
|
||||
import * as CalendarEvent from "./core/CalendarEvent";
|
||||
|
||||
export const calendarEventTagSchema = z
|
||||
const calendarEventTagSchema = z
|
||||
.string()
|
||||
.refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag));
|
||||
|
||||
|
||||
@@ -25,12 +25,13 @@ const defaultBracket = (): Progression.InputBracket => ({
|
||||
export function BracketProgressionSelector({
|
||||
initialBrackets,
|
||||
isInvitationalTournament,
|
||||
setErrored,
|
||||
onChange,
|
||||
isTournamentInProgress,
|
||||
}: {
|
||||
initialBrackets?: Progression.InputBracket[];
|
||||
isInvitationalTournament: boolean;
|
||||
setErrored: (errored: boolean) => void;
|
||||
/** Emits the validated brackets while valid, or `null` while invalid/incomplete. */
|
||||
onChange: (value: Progression.ParsedBracket[] | null) => void;
|
||||
isTournamentInProgress: boolean;
|
||||
}) {
|
||||
const [brackets, setBrackets] = React.useState<Progression.InputBracket[]>(
|
||||
@@ -75,24 +76,21 @@ export function BracketProgressionSelector({
|
||||
};
|
||||
|
||||
const validated = Progression.validatedBrackets(brackets);
|
||||
// `validatedBrackets` returns a fresh array each render, so emit only when the
|
||||
// serialized result actually changes — otherwise `onChange` would loop the form store.
|
||||
const serialized = Progression.isBrackets(validated)
|
||||
? JSON.stringify(validated)
|
||||
: null;
|
||||
const lastSerialized = React.useRef<string | null | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (Progression.isError(validated)) {
|
||||
setErrored(true);
|
||||
} else {
|
||||
setErrored(false);
|
||||
}
|
||||
}, [validated, setErrored]);
|
||||
if (lastSerialized.current === serialized) return;
|
||||
lastSerialized.current = serialized;
|
||||
onChange(serialized ? JSON.parse(serialized) : null);
|
||||
}, [serialized, onChange]);
|
||||
|
||||
return (
|
||||
<div className="stack lg items-start">
|
||||
{Progression.isBrackets(validated) ? (
|
||||
<input
|
||||
type="hidden"
|
||||
name="bracketProgression"
|
||||
value={JSON.stringify(validated)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="stack lg">
|
||||
{brackets.map((bracket, i) => (
|
||||
<TournamentFormatBracketSelector
|
||||
|
||||
@@ -93,6 +93,18 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// the badges the user can pick from, plus any already-attached prize badges they no
|
||||
// longer manage (so an existing selection still renders and stays removable)
|
||||
const badgeOptions = R.uniqueBy(
|
||||
[...managedBadges, ...(eventToEdit?.badgePrizes ?? [])].map((badge) => ({
|
||||
id: badge.id,
|
||||
code: badge.code,
|
||||
displayName: badge.displayName,
|
||||
hue: badge.hue,
|
||||
})),
|
||||
(badge) => badge.id,
|
||||
);
|
||||
|
||||
return {
|
||||
isAddingTournament: Boolean(
|
||||
url.searchParams.has("tournament") ||
|
||||
@@ -100,6 +112,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
eventToEdit?.tournament,
|
||||
),
|
||||
managedBadges,
|
||||
badgeOptions,
|
||||
eventToEdit: canEditEvent ? eventToEdit : undefined,
|
||||
eventToCopy,
|
||||
recentTournaments:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -308,6 +308,7 @@ function ClockHeader({
|
||||
className={clsx({
|
||||
"text-lighter italic": isInThePast,
|
||||
})}
|
||||
data-testid="clock-header-time"
|
||||
/>
|
||||
) : (
|
||||
<LocaleTime
|
||||
@@ -316,6 +317,7 @@ function ClockHeader({
|
||||
})}
|
||||
date={date}
|
||||
options={timeOptions}
|
||||
data-testid="clock-header-time"
|
||||
/>
|
||||
)}
|
||||
{hiddenEventsCount > 0 ? (
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { sub } from "date-fns";
|
||||
import { db } from "~/db/sql";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
||||
export function upsertOwn(url: string) {
|
||||
return db
|
||||
.insertInto("RoomLink")
|
||||
.values({
|
||||
userId: actorId(),
|
||||
url,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column("userId").doUpdateSet({
|
||||
url,
|
||||
createdAt: databaseTimestampNow(),
|
||||
refreshedAt: databaseTimestampNow(),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findByUserIds(userIds: number[], maxAgeHours: number) {
|
||||
return db
|
||||
.selectFrom("RoomLink")
|
||||
.select([
|
||||
"RoomLink.userId",
|
||||
"RoomLink.url",
|
||||
"RoomLink.createdAt",
|
||||
"RoomLink.refreshedAt",
|
||||
])
|
||||
.where("RoomLink.userId", "in", userIds)
|
||||
.where(
|
||||
"RoomLink.createdAt",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(sub(new Date(), { hours: maxAgeHours })),
|
||||
)
|
||||
.orderBy("RoomLink.refreshedAt", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function refreshOwnTimestamp() {
|
||||
return db
|
||||
.updateTable("RoomLink")
|
||||
.set({ refreshedAt: databaseTimestampNow() })
|
||||
.where("userId", "=", actorId())
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function deleteOld() {
|
||||
return db
|
||||
.deleteFrom("RoomLink")
|
||||
.where(
|
||||
"refreshedAt",
|
||||
"<",
|
||||
dateToDatabaseTimestamp(sub(new Date(), { hours: 2 })),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
extractRoomLink,
|
||||
findRoomLinks,
|
||||
isSplatnetRoomUrl,
|
||||
} from "./chat-constants";
|
||||
import { findRoomLinks, isSplatnetRoomUrl } from "./chat-constants";
|
||||
|
||||
describe("isSplatnetRoomUrl", () => {
|
||||
test("accepts canonical SplatNet share path", () => {
|
||||
@@ -111,15 +107,3 @@ describe("findRoomLinks", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRoomLink", () => {
|
||||
test("returns first valid link", () => {
|
||||
expect(extractRoomLink("hi https://s.nintendo.com/abc see you")).toBe(
|
||||
"https://s.nintendo.com/abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when no valid link present", () => {
|
||||
expect(extractRoomLink("https://sanintendoacom.evil.tld/abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,18 +31,6 @@ export function findRoomLinks(
|
||||
return results;
|
||||
}
|
||||
|
||||
export function extractRoomLink(text: string): string | null {
|
||||
return findRoomLinks(text)[0]?.url ?? null;
|
||||
}
|
||||
|
||||
const MATCH_ROOM_URL_PATTERN =
|
||||
/^\/q\/match\/\d+$|^\/to\/\d+\/matches\/\d+$|^\/scrims\/\d+$/;
|
||||
|
||||
export function isMatchRoomUrl(url: string) {
|
||||
const pathname = canParseUrl(url) ? new URL(url).pathname : url;
|
||||
return MATCH_ROOM_URL_PATTERN.test(pathname);
|
||||
}
|
||||
|
||||
function canParseUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
|
||||
@@ -107,6 +107,20 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.roomLinkBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-1);
|
||||
margin-block: var(--s-1);
|
||||
}
|
||||
|
||||
.roomQrCode {
|
||||
background-color: white;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.roomLink {
|
||||
color: var(--color-text-accent);
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { sub } from "date-fns";
|
||||
import { SendHorizontal } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import * as React from "react";
|
||||
import { Button } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -325,15 +326,17 @@ function MessageContents({ text }: { text: string }) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
key={i}
|
||||
href={match.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.roomLink}
|
||||
>
|
||||
{match.url}
|
||||
</a>,
|
||||
<span key={i} className={styles.roomLinkBlock}>
|
||||
<QRCodeSVG value={match.url} size={120} className={styles.roomQrCode} />
|
||||
<a
|
||||
href={match.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.roomLink}
|
||||
>
|
||||
{match.url}
|
||||
</a>
|
||||
</span>,
|
||||
);
|
||||
lastIndex = match.index + match.url.length;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import { useFetcher } from "react-router";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
interface RoomLink {
|
||||
userId: number;
|
||||
url: string;
|
||||
refreshedAt: number;
|
||||
}
|
||||
|
||||
interface ResolveActiveRoomLinkArgs {
|
||||
/** Room links for all match participants, sorted by `refreshedAt` ascending. */
|
||||
roomLinks: ReadonlyArray<RoomLink>;
|
||||
/** Database timestamp before which a link is considered stale (e.g. match start time). */
|
||||
freshnessCutoff: number;
|
||||
/** Viewer user id, used as fallback to surface the viewer's own stale link. */
|
||||
viewerUserId?: number;
|
||||
/** Members shown to resolve `hostedBy`. */
|
||||
members: ReadonlyArray<{ id: number; username: string }>;
|
||||
}
|
||||
|
||||
interface ActiveRoomLink {
|
||||
joinLink?: string;
|
||||
hostedBy?: string;
|
||||
isStale?: boolean;
|
||||
staleMinutesAgo: number;
|
||||
refreshedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the room link to display for a match. Prefers the oldest link refreshed
|
||||
* after the freshness cutoff (the host's confirmed room). Falls back to the
|
||||
* viewer's own stale link so they can refresh it themselves.
|
||||
*/
|
||||
export function resolveActiveRoomLink({
|
||||
roomLinks,
|
||||
freshnessCutoff,
|
||||
viewerUserId,
|
||||
members,
|
||||
}: ResolveActiveRoomLinkArgs): ActiveRoomLink {
|
||||
const validRoomLink = roomLinks.find(
|
||||
(rl) => rl.refreshedAt >= freshnessCutoff,
|
||||
);
|
||||
const ownStaleRoomLink = validRoomLink
|
||||
? undefined
|
||||
: roomLinks.find((rl) => rl.userId === viewerUserId);
|
||||
|
||||
const activeRoomLink = validRoomLink ?? ownStaleRoomLink;
|
||||
|
||||
return {
|
||||
joinLink: activeRoomLink?.url,
|
||||
hostedBy: activeRoomLink
|
||||
? members.find((m) => m.id === activeRoomLink.userId)?.username
|
||||
: undefined,
|
||||
isStale: activeRoomLink ? !validRoomLink : undefined,
|
||||
staleMinutesAgo: ownStaleRoomLink
|
||||
? differenceInMinutes(
|
||||
new Date(),
|
||||
databaseTimestampToDate(ownStaleRoomLink.refreshedAt),
|
||||
)
|
||||
: 0,
|
||||
refreshedAt: validRoomLink
|
||||
? databaseTimestampToDate(validRoomLink.refreshedAt)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Confirms the viewer's room link by refreshing its timestamp via the central `/room` action. */
|
||||
export function useConfirmRoom() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return {
|
||||
onConfirmRoom: () => {
|
||||
fetcher.submit(
|
||||
{ _action: "CONFIRM" },
|
||||
{ method: "post", action: "/room", encType: "application/json" },
|
||||
);
|
||||
},
|
||||
isConfirming: fetcher.state !== "idle",
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { parseRequestPayload } from "~/utils/remix.server";
|
||||
import { isSplatnetRoomUrl } from "../chat-constants";
|
||||
import * as RoomLinkRepository from "../RoomLinkRepository.server";
|
||||
|
||||
const roomLinkSchema = z.discriminatedUnion("_action", [
|
||||
z.object({
|
||||
_action: z.literal("UPSERT"),
|
||||
url: z.string().refine(isSplatnetRoomUrl, "Not a SplatNet room URL"),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("CONFIRM"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: roomLinkSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "UPSERT": {
|
||||
await RoomLinkRepository.upsertOwn(data.url);
|
||||
break;
|
||||
}
|
||||
case "CONFIRM": {
|
||||
await RoomLinkRepository.refreshOwnTimestamp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import styles from "~/styles/front.module.css";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
BLANK_IMAGE_URL,
|
||||
CALENDAR_PAGE,
|
||||
LUTI_PAGE,
|
||||
leaderboardsPage,
|
||||
navIconUrl,
|
||||
@@ -43,6 +44,7 @@ export default function FrontPage() {
|
||||
<LeagueBanner />
|
||||
<SeasonBanner />
|
||||
<SplatoonRotations />
|
||||
<TournamentShowcase />
|
||||
<ResultHighlights />
|
||||
<DiscoverFeatures />
|
||||
<ChangelogList />
|
||||
@@ -152,6 +154,27 @@ function LeagueBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentShowcase() {
|
||||
const { t } = useTranslation(["front"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.tournaments.showcase.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.tournamentCards}>
|
||||
<div className={clsx(styles.tournamentCardsSpacer, "scrollbar")}>
|
||||
{data.tournaments.showcase.map((tournament) => (
|
||||
<TournamentCard key={tournament.id} tournament={tournament} />
|
||||
))}
|
||||
</div>
|
||||
<Link to={CALENDAR_PAGE} className={styles.tournamentCardsViewAllCard}>
|
||||
<Image path={navIconUrl("medal")} size={36} alt="" />
|
||||
{t("front:showcase.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultHighlights() {
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
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";
|
||||
import { MatchPageHeader } from "~/components/match-page/MatchPageHeader";
|
||||
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
|
||||
@@ -96,6 +95,8 @@ export default function MatchPageTestRoute() {
|
||||
teamName: "Chimera",
|
||||
})}
|
||||
screenLegal={false}
|
||||
joinPool="SQ7"
|
||||
joinPass="8430"
|
||||
/>
|
||||
<MatchBannerBottomRow
|
||||
games={[{ mode: "SZ" }, { mode: "TC" }, { mode: "RM" }]}
|
||||
@@ -172,13 +173,7 @@ export default function MatchPageTestRoute() {
|
||||
/>
|
||||
</MatchBannerContainer>
|
||||
|
||||
<MatchTabs tabs={["join", "rosters", "action", "result"]}>
|
||||
<MatchJoinTab
|
||||
joinLink="https://app.nintendo.net/private_battle/abc123"
|
||||
pool="SQ7"
|
||||
pass="8430"
|
||||
showNoSplatnetAlert
|
||||
/>
|
||||
<MatchTabs tabs={["rosters", "action", "result"]}>
|
||||
<MatchRosterTab
|
||||
minMembersPerTeam={4}
|
||||
canEditSubbedOut={[true, false]}
|
||||
|
||||
@@ -14,7 +14,6 @@ export async function settingsByUserId(userId: number) {
|
||||
"User.vc",
|
||||
"User.languages",
|
||||
"User.noScreen",
|
||||
"User.noSplatnet",
|
||||
matchProfileWeapons(eb).as("weaponPool"),
|
||||
])
|
||||
.where("id", "=", userId)
|
||||
@@ -49,14 +48,12 @@ export async function updateOwnMatchProfile({
|
||||
languages,
|
||||
weaponPool,
|
||||
noScreen,
|
||||
noSplatnet,
|
||||
}: {
|
||||
mapModePreferences: UserMapModePreferences;
|
||||
vc: Tables["User"]["vc"];
|
||||
languages: string[];
|
||||
weaponPool: WeaponPoolItem[];
|
||||
noScreen: number;
|
||||
noSplatnet: number;
|
||||
}) {
|
||||
const userId = actorId();
|
||||
const currentPreferences = (
|
||||
@@ -102,7 +99,6 @@ export async function updateOwnMatchProfile({
|
||||
vc,
|
||||
languages: languages.length > 0 ? languages.join(",") : null,
|
||||
noScreen,
|
||||
noSplatnet,
|
||||
})
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
|
||||
@@ -26,7 +26,7 @@ function toTwoDecimals(value: number) {
|
||||
export function rate(teams: Team[], secondaryTeams?: [[Rating], [Rating]]) {
|
||||
if (secondaryTeams) return rateConservative(teams, secondaryTeams);
|
||||
|
||||
return openskillRate(teams, { tau: TAU, preventSigmaIncrease: true });
|
||||
return openskillRate(teams, { tau: TAU, limitSigma: true });
|
||||
}
|
||||
|
||||
// when ranking teams we rate the team against the actual team rating that it played against
|
||||
@@ -42,7 +42,7 @@ function rateConservative(
|
||||
teams,
|
||||
{
|
||||
tau: TAU,
|
||||
preventSigmaIncrease: true,
|
||||
limitSigma: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ function rateConservative(
|
||||
[secondaryTeams[0], teams[1]],
|
||||
{
|
||||
tau: TAU,
|
||||
preventSigmaIncrease: true,
|
||||
limitSigma: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ function rateConservative(
|
||||
[teams[0], secondaryTeams[1]],
|
||||
{
|
||||
tau: TAU,
|
||||
preventSigmaIncrease: true,
|
||||
limitSigma: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Namespace, TFunction } from "i18next";
|
||||
import type {
|
||||
AnyWeapon,
|
||||
DamageType,
|
||||
} from "~/features/build-analyzer/analyzer-types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import type { CombineWith } from "./calculator-types";
|
||||
import type objectDamages from "./core/object-dmg.json";
|
||||
import type { CombineWith, DamageReceiver } from "./calculator-types";
|
||||
import type objectDamages from "./data/object-dmg.json";
|
||||
|
||||
export const DAMAGE_RECEIVERS = [
|
||||
"Chariot", // Crab Tank
|
||||
@@ -29,6 +30,108 @@ export const DAMAGE_RECEIVERS = [
|
||||
"BulletShelterCanopyFocus_Launched", // Recycled Brella Canopy launched
|
||||
] as const;
|
||||
|
||||
type ReceiverTranslation =
|
||||
| { key: string }
|
||||
| { weaponKey: string; suffixKey: string };
|
||||
|
||||
/**
|
||||
* Maps each damage receiver to the i18n key(s) describing the object it represents. Some
|
||||
* receivers are a plain weapon/mode name, others combine a weapon name with a suffix (e.g.
|
||||
* "<weapon> Canopy"). Consumed via {@link translateDamageReceiver}.
|
||||
*/
|
||||
const damageReceiverTranslations: Record<DamageReceiver, ReceiverTranslation> =
|
||||
{
|
||||
Chariot: { key: "weapons:SPECIAL_12" },
|
||||
NiceBall_Armor: {
|
||||
weaponKey: "weapons:SPECIAL_6",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.armor",
|
||||
},
|
||||
ShockSonar: { key: "weapons:SPECIAL_7" },
|
||||
GreatBarrier_Barrier: {
|
||||
weaponKey: "weapons:SPECIAL_2",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.shield",
|
||||
},
|
||||
GreatBarrier_WeakPoint: {
|
||||
weaponKey: "weapons:SPECIAL_2",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.weakPoint",
|
||||
},
|
||||
BlowerInhale: {
|
||||
weaponKey: "weapons:SPECIAL_8",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.inhale",
|
||||
},
|
||||
Decoy: { key: "weapons:SPECIAL_16" },
|
||||
BulletPogo: { key: "weapons:SPECIAL_18" },
|
||||
Gachihoko_Barrier: {
|
||||
weaponKey: "game-misc:MODE_LONG_RM",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.shield",
|
||||
},
|
||||
Wsb_Flag: { key: "weapons:SUB_8" },
|
||||
Wsb_Shield: { key: "weapons:SUB_4" },
|
||||
Wsb_Sprinkler: { key: "weapons:SUB_3" },
|
||||
Bomb_TorpedoBullet: { key: "weapons:SUB_13" },
|
||||
BulletUmbrellaCanopyCompact: {
|
||||
weaponKey: "weapons:MAIN_6020",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyNormal: {
|
||||
weaponKey: "weapons:MAIN_6000",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyNormal_Launched: {
|
||||
weaponKey: "weapons:MAIN_6000",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
BulletUmbrellaCanopyWide: {
|
||||
weaponKey: "weapons:MAIN_6010",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyWide_Launched: {
|
||||
weaponKey: "weapons:MAIN_6010",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
BulletShelterCanopyFocus: {
|
||||
weaponKey: "weapons:MAIN_6030",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletShelterCanopyFocus_Launched: {
|
||||
weaponKey: "weapons:MAIN_6030",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the localized display name of a damage receiver using the given i18next `t` function.
|
||||
* The `weapons`, `analyzer` and `game-misc` namespaces must be available to the caller.
|
||||
*/
|
||||
export function translateDamageReceiver<Ns extends Namespace>(
|
||||
t: TFunction<Ns>,
|
||||
receiver: DamageReceiver,
|
||||
): string {
|
||||
const config = damageReceiverTranslations[receiver];
|
||||
if ("key" in config) {
|
||||
return t(config.key as never);
|
||||
}
|
||||
return t(config.suffixKey as never, {
|
||||
weapon: t(config.weaponKey as never),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The suffix-only localized label of a damage receiver (e.g. "Shield", "Weak Point"), or `null`
|
||||
* when the receiver is a plain weapon/mode name with no suffix. Used to disambiguate the parts of
|
||||
* a multi-part object (e.g. Big Bubbler's shield vs. weak point) without repeating the weapon name.
|
||||
*/
|
||||
export function damageReceiverSuffix<Ns extends Namespace>(
|
||||
t: TFunction<Ns>,
|
||||
receiver: DamageReceiver,
|
||||
): string | null {
|
||||
const config = damageReceiverTranslations[receiver];
|
||||
if ("key" in config) {
|
||||
return null;
|
||||
}
|
||||
return String(t(config.suffixKey as never, { weapon: "" })).trim();
|
||||
}
|
||||
|
||||
export const damagePriorities: Array<
|
||||
[
|
||||
AnyWeapon["type"],
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
damageTypesToCombine,
|
||||
} from "../calculator-constants";
|
||||
import type { CombineWith, DamageReceiver } from "../calculator-types";
|
||||
import objectDamages from "./object-dmg.json";
|
||||
import objectDamages from "../data/object-dmg.json";
|
||||
import { objectHitPoints } from "./objectHitPoints";
|
||||
|
||||
function damageTypeToMultipliers({
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
specialWeaponVariantImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { translateDamageReceiver } from "../calculator-constants";
|
||||
import { useObjectDamage } from "../calculator-hooks";
|
||||
import type { DamageReceiver } from "../calculator-types";
|
||||
import styles from "./object-damage-calculator.module.css";
|
||||
@@ -237,70 +238,6 @@ const damageReceiverAp: Partial<Record<DamageReceiver, JSX.Element>> = {
|
||||
),
|
||||
};
|
||||
|
||||
type ReceiverTranslation =
|
||||
| { key: string }
|
||||
| { weaponKey: string; suffixKey: string };
|
||||
|
||||
const damageReceiverTranslations: Record<DamageReceiver, ReceiverTranslation> =
|
||||
{
|
||||
Chariot: { key: "weapons:SPECIAL_12" },
|
||||
NiceBall_Armor: {
|
||||
weaponKey: "weapons:SPECIAL_6",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.armor",
|
||||
},
|
||||
ShockSonar: { key: "weapons:SPECIAL_7" },
|
||||
GreatBarrier_Barrier: {
|
||||
weaponKey: "weapons:SPECIAL_2",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.shield",
|
||||
},
|
||||
GreatBarrier_WeakPoint: {
|
||||
weaponKey: "weapons:SPECIAL_2",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.weakPoint",
|
||||
},
|
||||
BlowerInhale: {
|
||||
weaponKey: "weapons:SPECIAL_8",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.inhale",
|
||||
},
|
||||
Decoy: { key: "weapons:SPECIAL_16" },
|
||||
BulletPogo: { key: "weapons:SPECIAL_18" },
|
||||
Gachihoko_Barrier: {
|
||||
weaponKey: "game-misc:MODE_LONG_RM",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.shield",
|
||||
},
|
||||
Wsb_Flag: { key: "weapons:SUB_8" },
|
||||
Wsb_Shield: { key: "weapons:SUB_4" },
|
||||
Wsb_Sprinkler: { key: "weapons:SUB_3" },
|
||||
Bomb_TorpedoBullet: { key: "weapons:SUB_13" },
|
||||
BulletUmbrellaCanopyCompact: {
|
||||
weaponKey: "weapons:MAIN_6020",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyNormal: {
|
||||
weaponKey: "weapons:MAIN_6000",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyNormal_Launched: {
|
||||
weaponKey: "weapons:MAIN_6000",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
BulletUmbrellaCanopyWide: {
|
||||
weaponKey: "weapons:MAIN_6010",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyWide_Launched: {
|
||||
weaponKey: "weapons:MAIN_6010",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
BulletShelterCanopyFocus: {
|
||||
weaponKey: "weapons:MAIN_6030",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletShelterCanopyFocus_Launched: {
|
||||
weaponKey: "weapons:MAIN_6030",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
};
|
||||
|
||||
function DamageReceiversGrid({
|
||||
weapon,
|
||||
damagesToReceivers,
|
||||
@@ -316,13 +253,8 @@ function DamageReceiversGrid({
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation(["weapons", "analyzer", "common", "game-misc"]);
|
||||
|
||||
const translateReceiver = (receiver: DamageReceiver) => {
|
||||
const config = damageReceiverTranslations[receiver];
|
||||
if ("key" in config) {
|
||||
return t(config.key as any);
|
||||
}
|
||||
return t(config.suffixKey as any, { weapon: t(config.weaponKey as any) });
|
||||
};
|
||||
const translateReceiver = (receiver: DamageReceiver) =>
|
||||
translateDamageReceiver(t, receiver);
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
min-width: min(420px, 80vw);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--font-2xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.barTrack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.barFill {
|
||||
height: 18px;
|
||||
min-width: 2px;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.barFillCurrent {
|
||||
background-color: var(--color-second);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-xs);
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
}
|
||||
66
app/features/params/components/ParamComparisonDialog.tsx
Normal file
66
app/features/params/components/ParamComparisonDialog.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as R from "remeda";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import type {
|
||||
ParamComparisonEntry,
|
||||
WeaponParamKind,
|
||||
} from "../weapon-params-types";
|
||||
import styles from "./ParamComparisonDialog.module.css";
|
||||
import { WeaponParamImage } from "./WeaponParamsTable";
|
||||
|
||||
/**
|
||||
* Modal with a simple horizontal bar chart comparing one parameter's numeric value across the
|
||||
* currently visible weapons. Bars are scaled relative to the largest absolute value so weapons can
|
||||
* be compared at a glance.
|
||||
*/
|
||||
export function ParamComparisonDialog({
|
||||
kind,
|
||||
label,
|
||||
entries,
|
||||
currentWeaponId,
|
||||
onClose,
|
||||
}: {
|
||||
kind: WeaponParamKind;
|
||||
label: string;
|
||||
entries: ParamComparisonEntry[];
|
||||
currentWeaponId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
const sortedEntries = R.sortBy(entries, [(entry) => entry.value, "desc"]);
|
||||
const maxValue = Math.max(...entries.map((entry) => Math.abs(entry.value)));
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
heading={t("params:compare.heading", { parameter: label })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.bars}>
|
||||
{sortedEntries.map((entry) => (
|
||||
<div key={entry.weaponId} className={styles.row}>
|
||||
<div className={styles.weapon}>
|
||||
<WeaponParamImage kind={kind} id={entry.weaponId} size={28} />
|
||||
<span className={styles.name}>{entry.name}</span>
|
||||
</div>
|
||||
<div className={styles.barTrack}>
|
||||
<div
|
||||
className={clsx(styles.barFill, {
|
||||
[styles.barFillCurrent]: entry.weaponId === currentWeaponId,
|
||||
})}
|
||||
style={{
|
||||
width: `${maxValue === 0 ? 0 : (Math.abs(entry.value) / maxValue) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.value}>
|
||||
{WeaponParams.formatValue(entry.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
44
app/features/params/components/WeaponKits.module.css
Normal file
44
app/features/params/components/WeaponKits.module.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.kits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
background-color: var(--color-bg-high);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-1) var(--s-3);
|
||||
}
|
||||
|
||||
.kitName {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kitGear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.gearLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-rounded);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
50
app/features/params/components/WeaponKits.tsx
Normal file
50
app/features/params/components/WeaponKits.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { mySlugify, weaponParamsPage } from "~/utils/urls";
|
||||
import type { WeaponKitInfo } from "../weapon-params-types";
|
||||
import styles from "./WeaponKits.module.css";
|
||||
|
||||
export function WeaponKits({ kits }: { kits: WeaponKitInfo[] }) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<ul className={styles.kits}>
|
||||
{kits.map((kit) => (
|
||||
<li key={kit.weaponId} className={styles.kit}>
|
||||
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={28} />
|
||||
<span className={styles.kitName}>
|
||||
{t(`weapons:MAIN_${kit.weaponId}`)}
|
||||
</span>
|
||||
<span className={styles.kitGear}>
|
||||
<Link
|
||||
to={weaponParamsPage(
|
||||
mySlugify(t(`weapons:SUB_${kit.subWeaponId}`, { lng: "en" })),
|
||||
)}
|
||||
className={styles.gearLink}
|
||||
>
|
||||
<SubWeaponImage subWeaponId={kit.subWeaponId} size={22} />
|
||||
</Link>
|
||||
<Link
|
||||
to={weaponParamsPage(
|
||||
mySlugify(
|
||||
t(`weapons:SPECIAL_${kit.specialWeaponId}`, { lng: "en" }),
|
||||
),
|
||||
)}
|
||||
className={styles.gearLink}
|
||||
>
|
||||
<SpecialWeaponImage
|
||||
specialWeaponId={kit.specialWeaponId}
|
||||
size={22}
|
||||
/>
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
278
app/features/params/components/WeaponParamsTable.module.css
Normal file
278
app/features/params/components/WeaponParamsTable.module.css
Normal file
@@ -0,0 +1,278 @@
|
||||
.container {
|
||||
margin-top: var(--s-4);
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.hiddenBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
margin-bottom: var(--s-2);
|
||||
padding: var(--s-1-5) var(--s-2);
|
||||
background-color: var(--color-bg-high);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.hiddenBarLabel {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.hiddenBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.hiddenBadgeName {
|
||||
font-size: var(--font-2xs);
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hideButton {
|
||||
position: absolute;
|
||||
top: var(--s-1);
|
||||
right: var(--s-1);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: var(--font-xs);
|
||||
text-align: left;
|
||||
|
||||
& tbody {
|
||||
& tr:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
& td {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
& tr:first-child td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
& th {
|
||||
padding: var(--s-2);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.paramHeader {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.weaponHeader {
|
||||
position: relative;
|
||||
min-width: 90px;
|
||||
padding: var(--s-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weaponHeaderContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.weaponName {
|
||||
font-size: var(--font-2xs);
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.categoryHeader {
|
||||
background-color: var(--color-bg-high);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-xs);
|
||||
letter-spacing: 0.5px;
|
||||
padding: var(--s-1-5) var(--s-2);
|
||||
}
|
||||
|
||||
.paramName {
|
||||
font-size: var(--font-xs);
|
||||
min-width: 180px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.paramNameInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.expandableRow .paramName {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
.paramNameText {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.paramInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.historyIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.paramCell {
|
||||
font-size: var(--font-xs);
|
||||
vertical-align: top;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.cellContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.currentValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.specialPointKits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.specialPointKit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.historyBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding-inline: var(--s-0-5);
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-accent-low);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.noValue {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.historyList {
|
||||
margin-top: var(--s-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
font-size: var(--font-xs);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--s-1);
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.historyVersion {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.historyValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-high);
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.specialPointHistory {
|
||||
margin-top: var(--s-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--s-1);
|
||||
}
|
||||
|
||||
.specialPointHistoryKit {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-1);
|
||||
|
||||
& > div:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.specialPointHistoryKitList {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: var(--s-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.legendTitle {
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
margin: 0;
|
||||
padding-left: var(--s-3);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: var(--s-1);
|
||||
}
|
||||
}
|
||||
688
app/features/params/components/WeaponParamsTable.tsx
Normal file
688
app/features/params/components/WeaponParamsTable.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ChartColumnBig,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
EyeOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { InfoPopover } from "~/components/InfoPopover";
|
||||
import { translateDamageReceiver } from "~/features/object-damage-calculator/calculator-constants";
|
||||
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
|
||||
import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { mySlugify, weaponParamsPage } from "~/utils/urls";
|
||||
import { getParamExplanation } from "../core/param-explanations";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import { SPECIAL_POINTS_PARAM_KEY } from "../weapon-params-constants";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
ParamComparisonEntry,
|
||||
ParamValueWithHistory,
|
||||
SpecialPointWithHistory,
|
||||
WeaponParamKind,
|
||||
WeaponParamsTableProps,
|
||||
} from "../weapon-params-types";
|
||||
import { weaponTranslationKey } from "../weapon-params-types";
|
||||
import { ParamComparisonDialog } from "./ParamComparisonDialog";
|
||||
import styles from "./WeaponParamsTable.module.css";
|
||||
|
||||
const DAMAGE_RATE_INFO_CATEGORY = "DamageRateInfo";
|
||||
|
||||
export function WeaponParamImage({
|
||||
kind,
|
||||
id,
|
||||
size,
|
||||
}: {
|
||||
kind: WeaponParamKind;
|
||||
id: number;
|
||||
size: number;
|
||||
}) {
|
||||
if (kind === "sub") {
|
||||
return <SubWeaponImage subWeaponId={id as SubWeaponId} size={size} />;
|
||||
}
|
||||
if (kind === "special") {
|
||||
return (
|
||||
<SpecialWeaponImage specialWeaponId={id as SpecialWeaponId} size={size} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WeaponImage weaponSplId={id as MainWeaponId} variant="badge" size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
// The display name and (English) url slug of a weapon, resolved from the right `weapons`
|
||||
// translation key for the table's kind.
|
||||
function useWeaponParamNaming(kind: WeaponParamKind) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
const name = (id: number): string =>
|
||||
t(weaponTranslationKey(kind, id) as never);
|
||||
|
||||
const slug = (id: number) =>
|
||||
mySlugify(t(weaponTranslationKey(kind, id) as never, { lng: "en" }));
|
||||
|
||||
return { name, slug };
|
||||
}
|
||||
|
||||
export function WeaponParamsTable({
|
||||
kind,
|
||||
currentWeaponId,
|
||||
categoryWeaponIds,
|
||||
weaponParams,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
}: WeaponParamsTableProps) {
|
||||
const { t } = useTranslation(["weapons", "common", "analyzer", "params"]);
|
||||
const naming = useWeaponParamNaming(kind);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [comparison, setComparison] = useState<{
|
||||
label: string;
|
||||
entries: ParamComparisonEntry[];
|
||||
} | null>(null);
|
||||
|
||||
const paramDefinitions = WeaponParams.allParamKeys(weaponParams);
|
||||
|
||||
const paramsByCategory = R.groupBy(paramDefinitions, (def) => def.category);
|
||||
|
||||
const toggleRow = (fullKey: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fullKey)) {
|
||||
next.delete(fullKey);
|
||||
} else {
|
||||
next.add(fullKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const sortedWeaponIds = [
|
||||
currentWeaponId,
|
||||
...categoryWeaponIds.filter((id) => id !== currentWeaponId),
|
||||
];
|
||||
|
||||
const [hiddenWeaponIds, setHiddenWeaponIds] = useSearchParamStateEncoder<
|
||||
number[]
|
||||
>({
|
||||
name: "hidden",
|
||||
defaultValue: [],
|
||||
revive: (value) =>
|
||||
value
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter(
|
||||
(id) =>
|
||||
!Number.isNaN(id) &&
|
||||
id !== currentWeaponId &&
|
||||
categoryWeaponIds.includes(id),
|
||||
),
|
||||
encode: (ids) => ids.join(","),
|
||||
});
|
||||
|
||||
const hiddenSet = new Set(hiddenWeaponIds);
|
||||
const visibleWeaponIds = sortedWeaponIds.filter((id) => !hiddenSet.has(id));
|
||||
|
||||
const hideWeapon = (weaponId: number) =>
|
||||
setHiddenWeaponIds([...hiddenWeaponIds, weaponId]);
|
||||
const restoreWeapon = (weaponId: number) =>
|
||||
setHiddenWeaponIds(hiddenWeaponIds.filter((id) => id !== weaponId));
|
||||
const showAllWeapons = () => setHiddenWeaponIds([]);
|
||||
|
||||
const currentWeaponHasParam = (category: string, key: string) => {
|
||||
return Boolean(
|
||||
weaponParams[String(currentWeaponId)]?.categories[category]?.[key],
|
||||
);
|
||||
};
|
||||
|
||||
const rowHasHistory = (category: string, key: string) => {
|
||||
return visibleWeaponIds.some((id) => {
|
||||
const param = weaponParams[String(id)]?.categories[category]?.[key];
|
||||
return param && WeaponParams.hasHistory(param);
|
||||
});
|
||||
};
|
||||
|
||||
// Bars are only drawn for plain numbers, so string-valued (or array/object) params and hidden
|
||||
// weapons are skipped here. The compare button shows up only when at least two weapons remain.
|
||||
const comparisonEntries = (
|
||||
getValue: (weaponId: number) => number | string | undefined,
|
||||
) =>
|
||||
visibleWeaponIds
|
||||
.map((weaponId) => {
|
||||
const value = getValue(weaponId);
|
||||
return typeof value === "number"
|
||||
? { weaponId, value, name: naming.name(weaponId) }
|
||||
: null;
|
||||
})
|
||||
.filter((entry): entry is ParamComparisonEntry => entry !== null);
|
||||
|
||||
const openComparison = (label: string, entries: ParamComparisonEntry[]) =>
|
||||
setComparison({ label, entries });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
{hiddenWeaponIds.length > 0 ? (
|
||||
<HiddenWeaponsBar
|
||||
kind={kind}
|
||||
hiddenWeaponIds={hiddenWeaponIds}
|
||||
onRestore={restoreWeapon}
|
||||
onShowAll={showAllWeapons}
|
||||
/>
|
||||
) : null}
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.thead}>
|
||||
<tr>
|
||||
<th className={styles.paramHeader}>
|
||||
{t("params:header.parameter")}
|
||||
</th>
|
||||
{visibleWeaponIds.map((weaponId) => {
|
||||
const weaponName = naming.name(weaponId);
|
||||
const slug = naming.slug(weaponId);
|
||||
|
||||
return (
|
||||
<th key={weaponId} className={clsx(styles.weaponHeader, {})}>
|
||||
<Link
|
||||
to={weaponParamsPage(slug)}
|
||||
className={styles.weaponHeaderContent}
|
||||
>
|
||||
<WeaponParamImage kind={kind} id={weaponId} size={32} />
|
||||
<span className={styles.weaponName}>{weaponName}</span>
|
||||
</Link>
|
||||
{weaponId !== currentWeaponId ? (
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
shape="square"
|
||||
icon={<X />}
|
||||
className={styles.hideButton}
|
||||
onPress={() => hideWeapon(weaponId)}
|
||||
aria-label={t("common:actions.hide")}
|
||||
testId={`hide-weapon-${weaponId}`}
|
||||
/>
|
||||
) : null}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kind === "main" && specialPoints ? (
|
||||
<SpecialPointsRow
|
||||
visibleWeaponIds={visibleWeaponIds}
|
||||
specialPoints={specialPoints}
|
||||
isExpanded={expandedRows.has(SPECIAL_POINTS_PARAM_KEY)}
|
||||
onToggle={() => toggleRow(SPECIAL_POINTS_PARAM_KEY)}
|
||||
/>
|
||||
) : null}
|
||||
{Object.entries(paramsByCategory).map(([category, params]) => {
|
||||
const filteredParams = params.filter(({ key }) =>
|
||||
currentWeaponHasParam(category, key),
|
||||
);
|
||||
|
||||
if (filteredParams.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={category}>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={visibleWeaponIds.length + 1}
|
||||
className={styles.categoryHeader}
|
||||
>
|
||||
{category}
|
||||
</td>
|
||||
</tr>
|
||||
{filteredParams.map(({ key, fullKey }) => {
|
||||
const isExpanded = expandedRows.has(fullKey);
|
||||
const hasHistory = rowHasHistory(category, key);
|
||||
const explanation = getParamExplanation(category, key);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={fullKey}
|
||||
className={clsx({
|
||||
[styles.expandableRow]: hasHistory,
|
||||
})}
|
||||
>
|
||||
<td
|
||||
className={styles.paramName}
|
||||
onClick={
|
||||
hasHistory ? () => toggleRow(fullKey) : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.paramNameInner}>
|
||||
<span className={styles.paramNameText}>{key}</span>
|
||||
{hasHistory ? (
|
||||
<span className={styles.historyIndicator}>
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{explanation ? (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: stops the help popover click from toggling the history row
|
||||
<span
|
||||
className={styles.paramInfo}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<InfoPopover tiny>{explanation}</InfoPopover>
|
||||
</span>
|
||||
) : null}
|
||||
<ComparisonButton
|
||||
label={key}
|
||||
entries={comparisonEntries(
|
||||
(weaponId) =>
|
||||
weaponParams[String(weaponId)]?.categories[
|
||||
category
|
||||
]?.[key]?.current,
|
||||
)}
|
||||
onCompare={openComparison}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
{visibleWeaponIds.map((weaponId) => (
|
||||
<ParamCell
|
||||
key={weaponId}
|
||||
param={
|
||||
weaponParams[String(weaponId)]?.categories[
|
||||
category
|
||||
]?.[key]
|
||||
}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{damageMultipliers ? (
|
||||
<DamageRateInfoSection
|
||||
visibleWeaponIds={visibleWeaponIds}
|
||||
currentWeaponId={currentWeaponId}
|
||||
damageMultipliers={damageMultipliers}
|
||||
expandedRows={expandedRows}
|
||||
onToggle={toggleRow}
|
||||
comparisonEntries={comparisonEntries}
|
||||
onCompare={openComparison}
|
||||
/>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ParamsLegend />
|
||||
{comparison ? (
|
||||
<ParamComparisonDialog
|
||||
kind={kind}
|
||||
label={comparison.label}
|
||||
entries={comparison.entries}
|
||||
currentWeaponId={currentWeaponId}
|
||||
onClose={() => setComparison(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonButton({
|
||||
label,
|
||||
entries,
|
||||
onCompare,
|
||||
}: {
|
||||
label: string;
|
||||
entries: ParamComparisonEntry[];
|
||||
onCompare: (label: string, entries: ParamComparisonEntry[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
if (entries.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: stops the compare button click from toggling the history row
|
||||
<span className={styles.paramInfo} onClick={(e) => e.stopPropagation()}>
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
shape="square"
|
||||
icon={<ChartColumnBig />}
|
||||
onPress={() => onCompare(label, entries)}
|
||||
aria-label={t("params:compare.action")}
|
||||
testId="compare-param"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamsLegend() {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
return (
|
||||
<dl className={styles.legend}>
|
||||
<dt className={styles.legendTitle}>{t("params:legend.title")}</dt>
|
||||
<dd className={styles.legendItem}>{t("params:legend.damage")}</dd>
|
||||
<dd className={styles.legendItem}>{t("params:legend.frames")}</dd>
|
||||
<dd className={styles.legendItem}>{t("params:legend.powerUp")}</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function DamageRateInfoSection({
|
||||
visibleWeaponIds,
|
||||
currentWeaponId,
|
||||
damageMultipliers,
|
||||
expandedRows,
|
||||
onToggle,
|
||||
comparisonEntries,
|
||||
onCompare,
|
||||
}: {
|
||||
visibleWeaponIds: number[];
|
||||
currentWeaponId: number;
|
||||
damageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
|
||||
expandedRows: Set<string>;
|
||||
onToggle: (fullKey: string) => void;
|
||||
comparisonEntries: (
|
||||
getValue: (weaponId: number) => number | string | undefined,
|
||||
) => ParamComparisonEntry[];
|
||||
onCompare: (label: string, entries: ParamComparisonEntry[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons", "analyzer", "game-misc"]);
|
||||
|
||||
const targets = (damageMultipliers[String(currentWeaponId)] ?? []).map(
|
||||
(multiplier) => multiplier.target,
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const multiplierFor = (weaponId: number, target: string) =>
|
||||
damageMultipliers[String(weaponId)]?.find((m) => m.target === target);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={visibleWeaponIds.length + 1}
|
||||
className={styles.categoryHeader}
|
||||
>
|
||||
{DAMAGE_RATE_INFO_CATEGORY}
|
||||
</td>
|
||||
</tr>
|
||||
{targets.map((target) => {
|
||||
const fullKey = `${DAMAGE_RATE_INFO_CATEGORY}.${target}`;
|
||||
const isExpanded = expandedRows.has(fullKey);
|
||||
const hasHistory = visibleWeaponIds.some(
|
||||
(id) => (multiplierFor(id, target)?.history.length ?? 0) > 0,
|
||||
);
|
||||
const targetLabel = translateDamageReceiver(
|
||||
t,
|
||||
target as DamageReceiver,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={fullKey}
|
||||
className={clsx({ [styles.expandableRow]: hasHistory })}
|
||||
>
|
||||
<td
|
||||
className={styles.paramName}
|
||||
onClick={hasHistory ? () => onToggle(fullKey) : undefined}
|
||||
>
|
||||
<div className={styles.paramNameInner}>
|
||||
<span className={styles.paramNameText}>{targetLabel}</span>
|
||||
<ComparisonButton
|
||||
label={targetLabel}
|
||||
entries={comparisonEntries(
|
||||
(weaponId) => multiplierFor(weaponId, target)?.current,
|
||||
)}
|
||||
onCompare={onCompare}
|
||||
/>
|
||||
{hasHistory ? (
|
||||
<span className={styles.historyIndicator}>
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
{visibleWeaponIds.map((weaponId) => (
|
||||
<ParamCell
|
||||
key={weaponId}
|
||||
param={multiplierFor(weaponId, target)}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenWeaponsBar({
|
||||
kind,
|
||||
hiddenWeaponIds,
|
||||
onRestore,
|
||||
onShowAll,
|
||||
}: {
|
||||
kind: WeaponParamKind;
|
||||
hiddenWeaponIds: number[];
|
||||
onRestore: (weaponId: number) => void;
|
||||
onShowAll: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons", "common"]);
|
||||
const naming = useWeaponParamNaming(kind);
|
||||
|
||||
return (
|
||||
<div className={styles.hiddenBar}>
|
||||
<EyeOff
|
||||
size={16}
|
||||
className={styles.hiddenBarLabel}
|
||||
aria-label={t("common:actions.showAll")}
|
||||
/>
|
||||
{hiddenWeaponIds.map((weaponId) => (
|
||||
<SendouButton
|
||||
key={weaponId}
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
className={styles.hiddenBadge}
|
||||
onPress={() => onRestore(weaponId)}
|
||||
testId={`restore-weapon-${weaponId}`}
|
||||
>
|
||||
<WeaponParamImage kind={kind} id={weaponId} size={20} />
|
||||
<span className={styles.hiddenBadgeName}>
|
||||
{naming.name(weaponId)}
|
||||
</span>
|
||||
<X size={12} />
|
||||
</SendouButton>
|
||||
))}
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
onPress={onShowAll}
|
||||
testId="show-all-weapons"
|
||||
>
|
||||
{t("common:actions.showAll")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialPointsRow({
|
||||
visibleWeaponIds,
|
||||
specialPoints,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
visibleWeaponIds: number[];
|
||||
specialPoints: Record<string, SpecialPointWithHistory[]>;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["analyzer"]);
|
||||
|
||||
const hasHistory = visibleWeaponIds.some((id) =>
|
||||
specialPoints[String(id)]?.some((kit) => kit.history.length > 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={clsx({ [styles.expandableRow]: hasHistory })}>
|
||||
<td
|
||||
className={styles.paramName}
|
||||
onClick={hasHistory ? onToggle : undefined}
|
||||
>
|
||||
<div className={styles.paramNameInner}>
|
||||
<span className={styles.paramNameText}>
|
||||
{t("analyzer:stat.specialPoints")}
|
||||
</span>
|
||||
{hasHistory ? (
|
||||
<span className={styles.historyIndicator}>
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
{visibleWeaponIds.map((weaponId) => (
|
||||
<SpecialPointCell
|
||||
key={weaponId}
|
||||
kits={specialPoints[String(weaponId)] ?? []}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialPointCell({
|
||||
kits,
|
||||
isExpanded,
|
||||
}: {
|
||||
kits: SpecialPointWithHistory[];
|
||||
isExpanded: boolean;
|
||||
}) {
|
||||
if (kits.length === 0) {
|
||||
return (
|
||||
<td className={styles.paramCell}>
|
||||
<span className={styles.noValue}>—</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
const kitsWithHistory = kits.filter((kit) => kit.history.length > 0);
|
||||
const showHistory = isExpanded && kitsWithHistory.length > 0;
|
||||
const multiKit = kits.length > 1;
|
||||
|
||||
return (
|
||||
<td className={styles.paramCell}>
|
||||
<div className={styles.cellContent}>
|
||||
<div className={styles.specialPointKits}>
|
||||
{kits.map((kit) => (
|
||||
<div key={kit.weaponId} className={styles.specialPointKit}>
|
||||
{multiKit ? (
|
||||
<WeaponImage
|
||||
weaponSplId={kit.weaponId}
|
||||
variant="badge"
|
||||
size={18}
|
||||
/>
|
||||
) : null}
|
||||
<span className={styles.currentValue}>{kit.current}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{kitsWithHistory.length > 0 && !isExpanded ? (
|
||||
<span className={styles.historyBadge}>{kitsWithHistory.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{showHistory ? (
|
||||
<div className={styles.specialPointHistory}>
|
||||
{kitsWithHistory.map((kit) => (
|
||||
<div key={kit.weaponId} className={styles.specialPointHistoryKit}>
|
||||
{multiKit ? (
|
||||
<WeaponImage
|
||||
weaponSplId={kit.weaponId}
|
||||
variant="badge"
|
||||
size={16}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.specialPointHistoryKitList}>
|
||||
{kit.history.toReversed().map(({ version, value }) => (
|
||||
<div key={version} className={styles.historyItem}>
|
||||
<span className={styles.historyValue}>{value}</span>
|
||||
<span className={styles.historyVersion}>{version}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamCell({
|
||||
param,
|
||||
isExpanded,
|
||||
}: {
|
||||
param: ParamValueWithHistory | undefined;
|
||||
isExpanded: boolean;
|
||||
}) {
|
||||
if (!param) {
|
||||
return (
|
||||
<td className={styles.paramCell}>
|
||||
<span className={styles.noValue}>—</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
const showHistory = isExpanded && param.history.length > 0;
|
||||
|
||||
return (
|
||||
<td className={styles.paramCell}>
|
||||
<div className={styles.cellContent}>
|
||||
<span className={styles.currentValue}>
|
||||
{WeaponParams.formatValue(param.current)}
|
||||
</span>
|
||||
{param.history.length > 0 && !isExpanded ? (
|
||||
<span className={styles.historyBadge}>{param.history.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{showHistory ? (
|
||||
<div className={styles.historyList}>
|
||||
{param.history.toReversed().map(({ version, value }) => (
|
||||
<div key={version} className={styles.historyItem}>
|
||||
<span className={styles.historyValue}>
|
||||
{WeaponParams.formatValue(value)}
|
||||
</span>
|
||||
<span className={styles.historyVersion}>{version}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
16
app/features/params/components/WeaponParamsView.module.css
Normal file
16
app/features/params/components/WeaponParamsView.module.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dataCredit {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
text-decoration: underline;
|
||||
}
|
||||
109
app/features/params/components/WeaponParamsView.tsx
Normal file
109
app/features/params/components/WeaponParamsView.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
KitPatchHistory,
|
||||
ParsedWeaponParams,
|
||||
SpecialPointWithHistory,
|
||||
WeaponKitInfo,
|
||||
WeaponParamKind,
|
||||
WeaponPatch,
|
||||
} from "../weapon-params-types";
|
||||
import { WeaponKits } from "./WeaponKits";
|
||||
import { WeaponParamsTable } from "./WeaponParamsTable";
|
||||
import styles from "./WeaponParamsView.module.css";
|
||||
import {
|
||||
WeaponPatchHistory,
|
||||
WeaponPatchHistoryByKit,
|
||||
} from "./WeaponPatchHistory";
|
||||
|
||||
export function WeaponParamsView({
|
||||
kind,
|
||||
weaponId,
|
||||
categoryWeaponIds,
|
||||
weaponParams,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
versions,
|
||||
patchHistory,
|
||||
kitPatchHistories,
|
||||
kits,
|
||||
}: {
|
||||
kind: WeaponParamKind;
|
||||
weaponId: number;
|
||||
categoryWeaponIds: number[];
|
||||
weaponParams: Record<string, ParsedWeaponParams>;
|
||||
specialPoints?: Record<string, SpecialPointWithHistory[]>;
|
||||
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
|
||||
versions: string[];
|
||||
patchHistory: WeaponPatch[];
|
||||
kitPatchHistories?: KitPatchHistory[];
|
||||
kits?: WeaponKitInfo[];
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
const [tab, setTab] = useSearchParamState({
|
||||
name: "tab",
|
||||
defaultValue: "params",
|
||||
revive: (value) => (value === "patches" ? "patches" : "params"),
|
||||
});
|
||||
|
||||
const viewedKitPatchCount =
|
||||
kitPatchHistories?.find((kit) => kit.weaponId === weaponId)?.patches
|
||||
.length ?? patchHistory.length;
|
||||
|
||||
return (
|
||||
<Main className={styles.container} bigger>
|
||||
{kits ? <WeaponKits kits={kits} /> : null}
|
||||
<SendouTabs
|
||||
selectedKey={tab}
|
||||
onSelectionChange={(key) => setTab(String(key))}
|
||||
className={styles.tabs}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="params">{t("params:tab.params")}</SendouTab>
|
||||
<SendouTab id="patches" number={viewedKitPatchCount}>
|
||||
{t("params:tab.patches")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="params">
|
||||
<WeaponParamsTable
|
||||
kind={kind}
|
||||
currentWeaponId={weaponId}
|
||||
categoryWeaponIds={categoryWeaponIds}
|
||||
weaponParams={weaponParams}
|
||||
specialPoints={specialPoints}
|
||||
damageMultipliers={damageMultipliers}
|
||||
versions={versions}
|
||||
/>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="patches">
|
||||
{kitPatchHistories && kitPatchHistories.length > 0 ? (
|
||||
<WeaponPatchHistoryByKit
|
||||
kits={kitPatchHistories}
|
||||
defaultWeaponId={weaponId as MainWeaponId}
|
||||
/>
|
||||
) : (
|
||||
<WeaponPatchHistory patches={patchHistory} />
|
||||
)}
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
<a
|
||||
href="https://leanny.github.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.dataCredit}
|
||||
>
|
||||
{t("params:dataCredit.lean")}
|
||||
</a>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
176
app/features/params/components/WeaponPatchHistory.module.css
Normal file
176
app/features/params/components/WeaponPatchHistory.module.css
Normal file
@@ -0,0 +1,176 @@
|
||||
.kitHistory {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-2) var(--s-4);
|
||||
}
|
||||
|
||||
.kitChip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--s-2);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-block: var(--s-3);
|
||||
}
|
||||
}
|
||||
|
||||
.dividerLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
flex: 0 0 auto;
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) var(--s-2);
|
||||
border: 2px solid var(--color-border-high);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.change {
|
||||
min-height: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
font-size: var(--font-2xs);
|
||||
|
||||
&.wide,
|
||||
&.incoming {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&.buff {
|
||||
border-color: var(--color-success);
|
||||
background-color: var(--color-success-low);
|
||||
}
|
||||
|
||||
&.nerf {
|
||||
border-color: var(--color-error);
|
||||
background-color: var(--color-error-low);
|
||||
}
|
||||
}
|
||||
|
||||
.changeName {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.change.wide & {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.changeIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changeValues {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
flex-shrink: 0;
|
||||
font-weight: var(--weight-semi);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.change.wide & {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.attackers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attackerIcons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.attackerSuffix {
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-sm);
|
||||
padding: var(--s-4);
|
||||
}
|
||||
389
app/features/params/components/WeaponPatchHistory.tsx
Normal file
389
app/features/params/components/WeaponPatchHistory.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import {
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import {
|
||||
damageReceiverSuffix,
|
||||
translateDamageReceiver,
|
||||
} from "~/features/object-damage-calculator/calculator-constants";
|
||||
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
|
||||
import {
|
||||
useSearchParamState,
|
||||
useSearchParamStateEncoder,
|
||||
} from "~/hooks/useSearchParamState";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import {
|
||||
DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
SPECIAL_POINTS_PARAM_KEY,
|
||||
} from "../weapon-params-constants";
|
||||
import type {
|
||||
IncomingDamageAttackers,
|
||||
KitPatchHistory,
|
||||
PatchChange,
|
||||
WeaponPatch,
|
||||
} from "../weapon-params-types";
|
||||
import styles from "./WeaponPatchHistory.module.css";
|
||||
|
||||
const PATCH_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
export function WeaponPatchHistory({ patches }: { patches: WeaponPatch[] }) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
if (patches.length === 0) {
|
||||
return <div className={styles.empty}>{t("params:noPatches")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{patches.map((patch) => (
|
||||
<div key={patch.version} className={styles.column}>
|
||||
<PatchColumnHeader version={patch.version} date={patch.date} />
|
||||
<div className={styles.changes}>
|
||||
{patch.changes.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch history of a main weapon shown one kit at a time: the selected kit's main weapon, sub
|
||||
* weapon and special weapon changes are grouped under dividers within the same patch column. Sub
|
||||
* and special weapon changes can be toggled off.
|
||||
*/
|
||||
export function WeaponPatchHistoryByKit({
|
||||
kits,
|
||||
defaultWeaponId,
|
||||
}: {
|
||||
kits: KitPatchHistory[];
|
||||
defaultWeaponId: MainWeaponId;
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
const kitIds = kits.map((kit) => kit.weaponId);
|
||||
|
||||
const [selectedWeaponId, setSelectedWeaponId] =
|
||||
useSearchParamStateEncoder<MainWeaponId>({
|
||||
name: "kit",
|
||||
defaultValue: defaultWeaponId,
|
||||
revive: (value) => {
|
||||
const id = Number(value) as MainWeaponId;
|
||||
return kitIds.includes(id) ? id : undefined;
|
||||
},
|
||||
encode: (value) => String(value),
|
||||
});
|
||||
|
||||
const [showSubSpecial, setShowSubSpecial] = useSearchParamState({
|
||||
name: "kitExtras",
|
||||
defaultValue: true,
|
||||
revive: (value) =>
|
||||
value === "false" ? false : value === "true" ? true : undefined,
|
||||
});
|
||||
|
||||
const selectedKit =
|
||||
kits.find((kit) => kit.weaponId === selectedWeaponId) ?? kits[0];
|
||||
|
||||
const patches = selectedKit.patches
|
||||
.map((patch) => ({
|
||||
...patch,
|
||||
changes: showSubSpecial
|
||||
? patch.changes
|
||||
: patch.changes.filter((change) => change.source === "main"),
|
||||
}))
|
||||
.filter((patch) => patch.changes.length > 0);
|
||||
|
||||
return (
|
||||
<div className={styles.kitHistory}>
|
||||
<div className={styles.controls}>
|
||||
{kits.length > 1 ? (
|
||||
<KitFilter
|
||||
kits={kits}
|
||||
selectedWeaponId={selectedKit.weaponId}
|
||||
onSelect={setSelectedWeaponId}
|
||||
/>
|
||||
) : null}
|
||||
<SendouSwitch isSelected={showSubSpecial} onChange={setShowSubSpecial}>
|
||||
{t("params:patches.showSubSpecial")}
|
||||
</SendouSwitch>
|
||||
</div>
|
||||
{patches.length === 0 ? (
|
||||
<div className={styles.empty}>{t("params:noPatches")}</div>
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
{patches.map((patch) => (
|
||||
<KitPatchColumn
|
||||
key={patch.version}
|
||||
patch={patch}
|
||||
kit={selectedKit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KitFilter({
|
||||
kits,
|
||||
selectedWeaponId,
|
||||
onSelect,
|
||||
}: {
|
||||
kits: KitPatchHistory[];
|
||||
selectedWeaponId: MainWeaponId;
|
||||
onSelect: (weaponId: MainWeaponId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<SendouChipRadioGroup>
|
||||
{kits.map((kit) => (
|
||||
<SendouChipRadio
|
||||
key={kit.weaponId}
|
||||
name="patch-history-kit"
|
||||
value={String(kit.weaponId)}
|
||||
checked={kit.weaponId === selectedWeaponId}
|
||||
onChange={(value) => onSelect(Number(value) as MainWeaponId)}
|
||||
>
|
||||
<span className={styles.kitChip}>
|
||||
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={20} />
|
||||
{t(`weapons:MAIN_${kit.weaponId}`)}
|
||||
</span>
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function KitPatchColumn({
|
||||
patch,
|
||||
kit,
|
||||
}: {
|
||||
patch: WeaponPatch;
|
||||
kit: KitPatchHistory;
|
||||
}) {
|
||||
const mainChanges = patch.changes.filter(
|
||||
(change) => change.source === "main",
|
||||
);
|
||||
const subChanges = patch.changes.filter((change) => change.source === "sub");
|
||||
const specialChanges = patch.changes.filter(
|
||||
(change) => change.source === "special",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.column}>
|
||||
<PatchColumnHeader version={patch.version} date={patch.date} />
|
||||
<div className={styles.changes}>
|
||||
{mainChanges.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
{subChanges.length > 0 ? (
|
||||
<>
|
||||
<SubWeaponDivider subWeaponId={kit.subWeaponId} />
|
||||
{subChanges.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{specialChanges.length > 0 ? (
|
||||
<>
|
||||
<SpecialWeaponDivider specialWeaponId={kit.specialWeaponId} />
|
||||
{specialChanges.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubWeaponDivider({ subWeaponId }: { subWeaponId: SubWeaponId }) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Divider smallText className={styles.divider}>
|
||||
<span className={styles.dividerLabel}>
|
||||
<SubWeaponImage subWeaponId={subWeaponId} size={18} />
|
||||
{t(`weapons:SUB_${subWeaponId}`)}
|
||||
</span>
|
||||
</Divider>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialWeaponDivider({
|
||||
specialWeaponId,
|
||||
}: {
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Divider smallText className={styles.divider}>
|
||||
<span className={styles.dividerLabel}>
|
||||
<SpecialWeaponImage specialWeaponId={specialWeaponId} size={18} />
|
||||
{t(`weapons:SPECIAL_${specialWeaponId}`)}
|
||||
</span>
|
||||
</Divider>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchColumnHeader({
|
||||
version,
|
||||
date,
|
||||
}: {
|
||||
version: string;
|
||||
date: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.version}>{version}</div>
|
||||
{date ? (
|
||||
<LocaleTime
|
||||
date={new Date(date)}
|
||||
options={PATCH_DATE_OPTIONS}
|
||||
className={styles.date}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function changeKey(change: PatchChange, index: number) {
|
||||
return `${change.category}.${change.key}.${change.weaponId ?? ""}.${change.source ?? ""}.${index}`;
|
||||
}
|
||||
|
||||
function ChangeBadge({ change }: { change: PatchChange }) {
|
||||
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
|
||||
|
||||
if (
|
||||
change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY &&
|
||||
change.attackers
|
||||
) {
|
||||
return <IncomingChangeBadge change={change} attackers={change.attackers} />;
|
||||
}
|
||||
|
||||
const isSpecialPoints = change.category === SPECIAL_POINTS_PARAM_KEY;
|
||||
const isDamageMultiplier = change.category === DAMAGE_MULTIPLIER_PARAM_KEY;
|
||||
// Damage falloff curves serialize to long "damage @ distance" lists that need their own line.
|
||||
const isWideValue =
|
||||
typeof change.from === "string" && change.from.includes("@");
|
||||
|
||||
const label = isSpecialPoints
|
||||
? t("analyzer:stat.specialPoints")
|
||||
: isDamageMultiplier
|
||||
? translateDamageReceiver(t, change.key as DamageReceiver)
|
||||
: change.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.change, {
|
||||
[styles.buff]: change.kind === "buff",
|
||||
[styles.nerf]: change.kind === "nerf",
|
||||
[styles.wide]: isWideValue,
|
||||
})}
|
||||
title={
|
||||
isSpecialPoints || isDamageMultiplier
|
||||
? undefined
|
||||
: `${change.category}.${change.key}`
|
||||
}
|
||||
>
|
||||
<span className={styles.changeName}>
|
||||
{isSpecialPoints && change.weaponId ? (
|
||||
<WeaponImage
|
||||
weaponSplId={change.weaponId}
|
||||
variant="badge"
|
||||
size={20}
|
||||
className={styles.changeIcon}
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
</span>
|
||||
<span className={styles.changeValues}>
|
||||
{WeaponParams.formatValue(change.from)}
|
||||
<span className={styles.arrow}>→</span>
|
||||
{WeaponParams.formatValue(change.to)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An incoming damage multiplier change: a set of attacking weapons whose shared damage rate against
|
||||
* the page's sub or special weapon changed. Shows the attacking weapons' icons (with a suffix for
|
||||
* multi-part objects, e.g. a Big Bubbler's shield vs. weak point) and the from→to rate.
|
||||
*/
|
||||
function IncomingChangeBadge({
|
||||
change,
|
||||
attackers,
|
||||
}: {
|
||||
change: PatchChange;
|
||||
attackers: IncomingDamageAttackers;
|
||||
}) {
|
||||
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
|
||||
|
||||
const suffix = damageReceiverSuffix(t, change.key as DamageReceiver);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.change, styles.incoming, {
|
||||
[styles.buff]: change.kind === "buff",
|
||||
[styles.nerf]: change.kind === "nerf",
|
||||
})}
|
||||
title={translateDamageReceiver(t, change.key as DamageReceiver)}
|
||||
>
|
||||
<div className={styles.attackers}>
|
||||
<span className={styles.attackerIcons}>
|
||||
{attackers.mainWeaponIds.map((id) => (
|
||||
<WeaponImage
|
||||
key={`m-${id}`}
|
||||
weaponSplId={id}
|
||||
variant="badge"
|
||||
size={20}
|
||||
/>
|
||||
))}
|
||||
{attackers.subWeaponIds.map((id) => (
|
||||
<SubWeaponImage key={`s-${id}`} subWeaponId={id} size={20} />
|
||||
))}
|
||||
{attackers.specialWeaponIds.map((id) => (
|
||||
<SpecialWeaponImage
|
||||
key={`x-${id}`}
|
||||
specialWeaponId={id}
|
||||
size={20}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
{suffix ? (
|
||||
<span className={styles.attackerSuffix}>{suffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={styles.changeValues}>
|
||||
{WeaponParams.formatValue(change.from)}
|
||||
<span className={styles.arrow}>→</span>
|
||||
{WeaponParams.formatValue(change.to)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
app/features/params/core/WeaponParams.test.ts
Normal file
473
app/features/params/core/WeaponParams.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
SPECIAL_POINTS_PARAM_KEY,
|
||||
} from "../weapon-params-constants";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
ParsedWeaponParams,
|
||||
} from "../weapon-params-types";
|
||||
import { classifyParamChange } from "./param-directions";
|
||||
import * as WeaponParams from "./WeaponParams";
|
||||
|
||||
const VERSIONS = ["1.0.0", "2.0.0", "3.0.0"];
|
||||
|
||||
const emptyParsed = (weaponId: number): ParsedWeaponParams => ({
|
||||
weaponId,
|
||||
categories: {},
|
||||
});
|
||||
|
||||
const row = (overrides: {
|
||||
mainWeaponIds?: number[];
|
||||
subWeaponIds?: number[];
|
||||
specialWeaponIds?: number[];
|
||||
targets: DamageMultiplierWithHistory[];
|
||||
}) => ({
|
||||
mainWeaponIds: [],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("damageMultipliersForWeapon", () => {
|
||||
it("collects only rows applying to the weapon for the given kind", () => {
|
||||
const rows = {
|
||||
a: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [{ target: "Chariot", current: 2, history: [] }],
|
||||
}),
|
||||
b: row({
|
||||
specialWeaponIds: [12],
|
||||
targets: [{ target: "ShockSonar", current: 2, history: [] }],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
|
||||
|
||||
expect(result.map((m) => m.target)).toEqual(["Chariot"]);
|
||||
});
|
||||
|
||||
it("de-duplicates identical target histories shared across rows", () => {
|
||||
const sharedTarget: DamageMultiplierWithHistory = {
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
};
|
||||
const rows = {
|
||||
bullet: row({ specialWeaponIds: [10], targets: [sharedTarget] }),
|
||||
bombCore: row({
|
||||
specialWeaponIds: [10],
|
||||
targets: [{ ...sharedTarget }],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 10, "special");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("merges several rows of the same target into the most informative entry", () => {
|
||||
const rows = {
|
||||
swing: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [{ target: "Chariot", current: 4.5, history: [] }],
|
||||
}),
|
||||
throwBombCore: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [
|
||||
{
|
||||
target: "Chariot",
|
||||
current: 3.273,
|
||||
history: [
|
||||
{ version: "2.0.0", value: 2 },
|
||||
{ version: "3.0.0", value: 6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].history).toHaveLength(2);
|
||||
expect(result[0].current).toBe(3.273);
|
||||
});
|
||||
|
||||
it("orders entries like DAMAGE_RECEIVERS", () => {
|
||||
const rows = {
|
||||
a: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [
|
||||
{ target: "Wsb_Shield", current: 2, history: [] },
|
||||
{ target: "Chariot", current: 3, history: [] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
|
||||
|
||||
// Chariot precedes Wsb_Shield in DAMAGE_RECEIVERS
|
||||
expect(result.map((m) => m.target)).toEqual(["Chariot", "Wsb_Shield"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchHistory damage multipliers", () => {
|
||||
const buildWith = (multiplier: DamageMultiplierWithHistory) =>
|
||||
WeaponParams.patchHistory(emptyParsed(11), VERSIONS, [], [multiplier]);
|
||||
|
||||
it("attributes a change to the version after the recorded one and flags a higher rate as a buff", () => {
|
||||
const patches = buildWith({
|
||||
target: "Wsb_Shield",
|
||||
current: 2.2,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
});
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].version).toBe("2.0.0");
|
||||
expect(patches[0].changes).toEqual([
|
||||
{
|
||||
category: DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: "Wsb_Shield",
|
||||
from: 2,
|
||||
to: 2.2,
|
||||
kind: "buff",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags a lower rate as a nerf", () => {
|
||||
const patches = buildWith({
|
||||
target: "NiceBall_Armor",
|
||||
current: 1.82,
|
||||
history: [{ version: "2.0.0", value: 2.6 }],
|
||||
});
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].version).toBe("3.0.0");
|
||||
expect(patches[0].changes[0].kind).toBe("nerf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("incomingDamageMultipliersForWeapon", () => {
|
||||
it("collects other weapons' rates against the weapon's receiver targets", () => {
|
||||
const rows = {
|
||||
fromSpecial: row({
|
||||
specialWeaponIds: [10],
|
||||
targets: [
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
fromMains: row({
|
||||
mainWeaponIds: [200, 201],
|
||||
targets: [
|
||||
{
|
||||
target: "GreatBarrier_WeakPoint",
|
||||
current: 3,
|
||||
history: [{ version: "2.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
unrelated: row({
|
||||
mainWeaponIds: [400],
|
||||
targets: [{ target: "Chariot", current: 2, history: [] }],
|
||||
}),
|
||||
};
|
||||
|
||||
// special id 2 is Big Bubbler (GreatBarrier_Barrier + GreatBarrier_WeakPoint)
|
||||
const result = WeaponParams.incomingDamageMultipliersForWeapon(
|
||||
rows,
|
||||
2,
|
||||
"special",
|
||||
);
|
||||
|
||||
expect(result.map((m) => m.target)).toEqual([
|
||||
"GreatBarrier_Barrier",
|
||||
"GreatBarrier_WeakPoint",
|
||||
]);
|
||||
expect(result[1].attackers.mainWeaponIds).toEqual([200, 201]);
|
||||
});
|
||||
|
||||
it("de-duplicates the same attacker group and target across rows", () => {
|
||||
const target = {
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
};
|
||||
const rows = {
|
||||
bullet: row({ specialWeaponIds: [10], targets: [target] }),
|
||||
bombCore: row({ specialWeaponIds: [10], targets: [{ ...target }] }),
|
||||
};
|
||||
|
||||
const result = WeaponParams.incomingDamageMultipliersForWeapon(
|
||||
rows,
|
||||
2,
|
||||
"special",
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns nothing for a weapon that is not a damageable object", () => {
|
||||
const rows = {
|
||||
a: row({
|
||||
specialWeaponIds: [10],
|
||||
targets: [
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
// special id 1 (Trizooka) is not in INCOMING_DAMAGE_RECEIVERS
|
||||
expect(
|
||||
WeaponParams.incomingDamageMultipliersForWeapon(rows, 1, "special"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchHistory incoming damage multipliers", () => {
|
||||
it("flags a higher incoming rate as a nerf to the defending weapon and carries the attackers", () => {
|
||||
const patches = WeaponParams.patchHistory(
|
||||
emptyParsed(2),
|
||||
VERSIONS,
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
attackers: {
|
||||
mainWeaponIds: [200],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
},
|
||||
current: 2.8,
|
||||
history: [{ version: "1.0.0", value: 1.4 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].version).toBe("2.0.0");
|
||||
expect(patches[0].changes[0]).toMatchObject({
|
||||
category: INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: "GreatBarrier_Barrier",
|
||||
from: 1.4,
|
||||
to: 2.8,
|
||||
kind: "nerf",
|
||||
attackers: { mainWeaponIds: [200] },
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a lower incoming rate as a buff to the defending weapon", () => {
|
||||
const patches = WeaponParams.patchHistory(
|
||||
emptyParsed(2),
|
||||
VERSIONS,
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
attackers: {
|
||||
mainWeaponIds: [200],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
},
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(patches[0].changes[0].kind).toBe("buff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse damage falloff curves", () => {
|
||||
it("serializes a DistanceDamage array into a scaled damage @ distance string", () => {
|
||||
const parsed = WeaponParams.parse(
|
||||
0,
|
||||
{
|
||||
BlastParam: {
|
||||
DistanceDamage: [
|
||||
{ Damage: 1800, Distance: 3.6 },
|
||||
{ Damage: 300, Distance: 7 },
|
||||
],
|
||||
},
|
||||
},
|
||||
VERSIONS,
|
||||
);
|
||||
|
||||
expect(parsed.categories.BlastParam.DistanceDamage.current).toBe(
|
||||
"180 @ 3.6, 30 @ 7",
|
||||
);
|
||||
});
|
||||
|
||||
it("flattens nested breakpoint arrays", () => {
|
||||
const parsed = WeaponParams.parse(
|
||||
0,
|
||||
{
|
||||
BlastParam: {
|
||||
DistanceDamage: [
|
||||
[{ Damage: 1800, Distance: 3.6 }],
|
||||
[{ Damage: 300, Distance: 7 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
VERSIONS,
|
||||
);
|
||||
|
||||
expect(parsed.categories.BlastParam.DistanceDamage.current).toBe(
|
||||
"180 @ 3.6, 30 @ 7",
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks per-version history of a damage falloff curve", () => {
|
||||
const parsed = WeaponParams.parse(
|
||||
0,
|
||||
{
|
||||
BlastParam: {
|
||||
DistanceDamage: [{ Damage: 600, Distance: 4 }],
|
||||
"DistanceDamage@2.0.0": [{ Damage: 400, Distance: 4 }],
|
||||
},
|
||||
},
|
||||
VERSIONS,
|
||||
);
|
||||
|
||||
expect(parsed.categories.BlastParam.DistanceDamage.history).toEqual([
|
||||
{ version: "2.0.0", value: "40 @ 4" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyParamChange damage falloff curves", () => {
|
||||
it("flags higher damage as a buff", () => {
|
||||
expect(
|
||||
classifyParamChange("BlastParam", "DistanceDamage", "40 @ 4", "60 @ 4"),
|
||||
).toBe("buff");
|
||||
});
|
||||
|
||||
it("flags lower damage as a nerf", () => {
|
||||
expect(
|
||||
classifyParamChange("BlastParam", "DistanceDamage", "60 @ 4", "40 @ 4"),
|
||||
).toBe("nerf");
|
||||
});
|
||||
|
||||
it("flags longer reach at the same damage as a buff", () => {
|
||||
expect(
|
||||
classifyParamChange(
|
||||
"BlastParam",
|
||||
"DistanceDamage",
|
||||
"70 @ 0.94, 50 @ 3.3",
|
||||
"70 @ 1.01, 50 @ 3.37",
|
||||
),
|
||||
).toBe("buff");
|
||||
});
|
||||
|
||||
it("flags shorter reach at the same damage as a nerf", () => {
|
||||
expect(
|
||||
classifyParamChange(
|
||||
"BlastParam",
|
||||
"DistanceDamage",
|
||||
"70 @ 1.01, 50 @ 3.37",
|
||||
"70 @ 0.975, 50 @ 3.37",
|
||||
),
|
||||
).toBe("nerf");
|
||||
});
|
||||
|
||||
it("is neutral when damage rises but reach shrinks", () => {
|
||||
expect(
|
||||
classifyParamChange("BlastParam", "DistanceDamage", "60 @ 4", "70 @ 3.5"),
|
||||
).toBe("neutral");
|
||||
});
|
||||
|
||||
it("is neutral when the curve gains or loses a breakpoint", () => {
|
||||
expect(
|
||||
classifyParamChange(
|
||||
"BlastParam",
|
||||
"DistanceDamage",
|
||||
"60 @ 4",
|
||||
"60 @ 4, 30 @ 8",
|
||||
),
|
||||
).toBe("neutral");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kitPatchHistories", () => {
|
||||
const kitHistory = () =>
|
||||
WeaponParams.kitPatchHistories({
|
||||
mainParsed: emptyParsed(11),
|
||||
versions: VERSIONS,
|
||||
kits: [{ weaponId: 11, subWeaponId: 1, specialWeaponId: 2 }],
|
||||
specialPointsByKit: {
|
||||
"11": {
|
||||
weaponId: 11,
|
||||
current: 180,
|
||||
history: [{ version: "1.0.0", value: 200 }],
|
||||
},
|
||||
},
|
||||
mainDamageMultipliers: [
|
||||
{
|
||||
target: "Wsb_Shield",
|
||||
current: 2.2,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
subParams: { "1": emptyParsed(1) },
|
||||
subDamageMultipliers: {
|
||||
"1": [
|
||||
{
|
||||
target: "Chariot",
|
||||
current: 3,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
subIncomingDamageMultipliers: {},
|
||||
specialParams: { "2": emptyParsed(2) },
|
||||
specialDamageMultipliers: {
|
||||
"2": [
|
||||
{
|
||||
target: "NiceBall_Armor",
|
||||
current: 1.5,
|
||||
history: [{ version: "2.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
specialIncomingDamageMultipliers: {},
|
||||
});
|
||||
|
||||
it("folds the kit's main, sub and special weapon changes into one descending history", () => {
|
||||
const [history] = kitHistory();
|
||||
|
||||
expect(history.weaponId).toBe(11);
|
||||
expect(history.patches.map((patch) => patch.version)).toEqual([
|
||||
"3.0.0",
|
||||
"2.0.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tags each change with its source and groups main before sub before special", () => {
|
||||
const [history] = kitHistory();
|
||||
|
||||
const v2 = history.patches.find((patch) => patch.version === "2.0.0")!;
|
||||
// special points + main damage rate (both main), then the sub weapon's damage rate
|
||||
expect(v2.changes.map((change) => change.source)).toEqual([
|
||||
"main",
|
||||
"main",
|
||||
"sub",
|
||||
]);
|
||||
expect(v2.changes[0].category).toBe(SPECIAL_POINTS_PARAM_KEY);
|
||||
|
||||
const v3 = history.patches.find((patch) => patch.version === "3.0.0")!;
|
||||
expect(v3.changes.map((change) => change.source)).toEqual(["special"]);
|
||||
});
|
||||
});
|
||||
774
app/features/params/core/WeaponParams.ts
Normal file
774
app/features/params/core/WeaponParams.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
import { PATCHES } from "~/features/builds/builds-constants";
|
||||
import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
weaponCategories,
|
||||
weaponIdToBaseWeaponId,
|
||||
weaponIdToType,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_RECEIVERS,
|
||||
SPECIAL_POINTS_PARAM_KEY,
|
||||
} from "../weapon-params-constants";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
IncomingDamageAttackers,
|
||||
IncomingDamageMultiplierWithHistory,
|
||||
KitPatchHistory,
|
||||
ParamDefinition,
|
||||
ParamValueWithHistory,
|
||||
ParsedWeaponParams,
|
||||
PatchChange,
|
||||
SpecialPointWithHistory,
|
||||
WeaponKitInfo,
|
||||
WeaponParamKind,
|
||||
WeaponPatch,
|
||||
} from "../weapon-params-types";
|
||||
import { classifyParamChange } from "./param-directions";
|
||||
|
||||
/**
|
||||
* Shape of the committed `all-version-*-params.json` data files: a map of weapon id to its raw
|
||||
* per-version params, the ordered list of tracked game versions, and (weapons only) special
|
||||
* points history.
|
||||
*/
|
||||
export interface AllVersionParams {
|
||||
metadata: { versions: string[] };
|
||||
weapons: Record<string, Record<string, Record<string, unknown>>>;
|
||||
specialPoints?: Record<
|
||||
string,
|
||||
{ history: Array<{ version: string; value: number }> }
|
||||
>;
|
||||
}
|
||||
|
||||
function parseParamKey(key: string): {
|
||||
baseKey: string;
|
||||
version: string | null;
|
||||
} {
|
||||
const atIndex = key.indexOf("@");
|
||||
if (atIndex === -1) {
|
||||
return { baseKey: key, version: null };
|
||||
}
|
||||
return {
|
||||
baseKey: key.slice(0, atIndex),
|
||||
version: key.slice(atIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
interface DistanceDamageBreakpoint {
|
||||
Damage: number;
|
||||
Distance: number;
|
||||
}
|
||||
|
||||
function isDistanceDamageBreakpoint(
|
||||
value: unknown,
|
||||
): value is DistanceDamageBreakpoint {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as DistanceDamageBreakpoint).Damage === "number" &&
|
||||
typeof (value as DistanceDamageBreakpoint).Distance === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a damage falloff curve: an array of {@link DistanceDamageBreakpoint}, with
|
||||
* each entry possibly being a nested array of breakpoints (e.g. fizzy bomb bounces).
|
||||
*/
|
||||
function isDistanceDamageArray(
|
||||
value: unknown[],
|
||||
): value is Array<DistanceDamageBreakpoint | DistanceDamageBreakpoint[]> {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(el) =>
|
||||
isDistanceDamageBreakpoint(el) ||
|
||||
(Array.isArray(el) &&
|
||||
el.length > 0 &&
|
||||
el.every(isDistanceDamageBreakpoint)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a damage falloff curve into a compact `"<damage> @ <distance>"` string (damage
|
||||
* scaled to displayed HP, i.e. divided by 10) so its per-version changes flow through the same
|
||||
* scalar param pipeline as plain values. Nested breakpoint arrays are flattened.
|
||||
*/
|
||||
function formatDistanceDamageArray(
|
||||
value: Array<DistanceDamageBreakpoint | DistanceDamageBreakpoint[]>,
|
||||
): string {
|
||||
return value
|
||||
.flat()
|
||||
.map(
|
||||
(breakpoint) =>
|
||||
`${formatValue(breakpoint.Damage / 10)} @ ${formatValue(breakpoint.Distance)}`,
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function flattenScalarParams(
|
||||
params: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): Array<[string, number | string]> {
|
||||
const result: Array<[string, number | string]> = [];
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === "number" || typeof value === "string") {
|
||||
result.push([fullKey, value]);
|
||||
} else if (Array.isArray(value)) {
|
||||
// Damage falloff curves and arrays of plain numbers/strings (e.g.
|
||||
// SplashSpawnParam.ForceSpawnNearestAddNumArray) are kept as a single joined string so
|
||||
// their per-version changes still show up. Other arrays of objects are too structured
|
||||
// to represent this way and are skipped.
|
||||
if (isDistanceDamageArray(value)) {
|
||||
result.push([fullKey, formatDistanceDamageArray(value)]);
|
||||
} else if (
|
||||
value.length > 0 &&
|
||||
value.every((el) => typeof el === "number" || typeof el === "string")
|
||||
) {
|
||||
result.push([
|
||||
fullKey,
|
||||
`[${value.map((el) => formatValue(el)).join(", ")}]`,
|
||||
]);
|
||||
}
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
result.push(
|
||||
...flattenScalarParams(value as Record<string, unknown>, fullKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single weapon's raw per-version params into the {@link ParsedWeaponParams} shape: each
|
||||
* parameter's current value plus its tracked history, grouped by category.
|
||||
*/
|
||||
export function parse(
|
||||
weaponId: number,
|
||||
rawParams: Record<string, Record<string, unknown>>,
|
||||
versions: string[],
|
||||
): ParsedWeaponParams {
|
||||
const categories: Record<string, Record<string, ParamValueWithHistory>> = {};
|
||||
|
||||
for (const [categoryName, categoryParams] of Object.entries(rawParams)) {
|
||||
if (
|
||||
typeof categoryParams !== "object" ||
|
||||
categoryParams === null ||
|
||||
Object.keys(categoryParams).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedParams: Record<string, ParamValueWithHistory> = {};
|
||||
const paramHistory: Record<
|
||||
string,
|
||||
{ current: number | string; versions: Map<string, number | string> }
|
||||
> = {};
|
||||
|
||||
for (const [key, value] of flattenScalarParams(categoryParams)) {
|
||||
const { baseKey, version } = parseParamKey(key);
|
||||
|
||||
if (!paramHistory[baseKey]) {
|
||||
paramHistory[baseKey] = {
|
||||
current: value,
|
||||
versions: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
if (version === null) {
|
||||
paramHistory[baseKey].current = value;
|
||||
} else {
|
||||
paramHistory[baseKey].versions.set(version, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [baseKey, data] of Object.entries(paramHistory)) {
|
||||
const history: Array<{ version: string; value: number | string }> = [];
|
||||
|
||||
for (const version of versions) {
|
||||
const historicalValue = data.versions.get(version);
|
||||
if (historicalValue !== undefined) {
|
||||
history.push({ version, value: historicalValue });
|
||||
}
|
||||
}
|
||||
|
||||
parsedParams[baseKey] = {
|
||||
current: data.current,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(parsedParams).length > 0) {
|
||||
categories[categoryName] = parsedParams;
|
||||
}
|
||||
}
|
||||
|
||||
return { weaponId, categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the params of every given weapon id from a static all-version params data file, keyed by
|
||||
* weapon id (as a string). Ids with no entry in the data are skipped. `toDataKey` maps a weapon id
|
||||
* to the id its params are stored under — main weapons share params with their base weapon, while
|
||||
* subs and specials use their own id (the default identity mapping).
|
||||
*/
|
||||
export function parseMany<Id extends number>(
|
||||
ids: readonly Id[],
|
||||
data: AllVersionParams,
|
||||
toDataKey: (id: Id) => number = (id) => id,
|
||||
): Record<string, ParsedWeaponParams> {
|
||||
const result: Record<string, ParsedWeaponParams> = {};
|
||||
|
||||
for (const id of ids) {
|
||||
const rawParams = data.weapons[String(toDataKey(id))];
|
||||
if (rawParams) {
|
||||
result[String(id)] = parse(id, rawParams, data.metadata.versions);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every distinct `${category}.${key}` parameter present across the given weapons, sorted
|
||||
* by category then key, for use as the comparison table's row definitions.
|
||||
*/
|
||||
export function allParamKeys(
|
||||
weaponParams: Record<string, ParsedWeaponParams>,
|
||||
): ParamDefinition[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const definitions: ParamDefinition[] = [];
|
||||
|
||||
for (const parsed of Object.values(weaponParams)) {
|
||||
for (const [category, params] of Object.entries(parsed.categories)) {
|
||||
for (const key of Object.keys(params)) {
|
||||
const fullKey = `${category}.${key}`;
|
||||
if (!seenKeys.has(fullKey)) {
|
||||
seenKeys.add(fullKey);
|
||||
definitions.push({ category, key, fullKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
definitions.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function getWeaponCategory(weaponId: MainWeaponId) {
|
||||
return weaponCategories.find((cat) =>
|
||||
(cat.weaponIds as readonly number[]).includes(weaponId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base main weapon ids of the given weapon's category, used as the columns its params
|
||||
* are compared against. A non-base weapon is kept first, followed by the other base weapons.
|
||||
*/
|
||||
export function categoryWeaponIds(weaponId: MainWeaponId): MainWeaponId[] {
|
||||
const category = getWeaponCategory(weaponId);
|
||||
if (!category) {
|
||||
return [weaponId];
|
||||
}
|
||||
|
||||
const baseWeapons = (category.weaponIds as readonly MainWeaponId[]).filter(
|
||||
(id) => weaponIdToType(id) === "BASE",
|
||||
);
|
||||
|
||||
if (baseWeapons.includes(weaponId)) {
|
||||
return baseWeapons;
|
||||
}
|
||||
|
||||
const currentWeaponBaseId = weaponIdToBaseWeaponId(weaponId);
|
||||
return [weaponId, ...baseWeapons.filter((id) => id !== currentWeaponBaseId)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the main weapon ids that are kit siblings of the given weapon, i.e. they share
|
||||
* the same base weapon (e.g. a weapon and its alternate kit) but excluding cosmetic alt
|
||||
* skins. The returned list includes the given weapon itself.
|
||||
*/
|
||||
export function kitSiblingIds(weaponId: MainWeaponId): MainWeaponId[] {
|
||||
const baseId = weaponIdToBaseWeaponId(weaponId);
|
||||
return mainWeaponIds.filter(
|
||||
(id) =>
|
||||
weaponIdToBaseWeaponId(id) === baseId &&
|
||||
weaponIdToType(id) !== "ALT_SKIN",
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the given parameter has any tracked per-version history. */
|
||||
export function hasHistory(param: ParamValueWithHistory): boolean {
|
||||
return param.history.length > 0;
|
||||
}
|
||||
|
||||
interface DamageRateHistoryRow {
|
||||
mainWeaponIds: number[];
|
||||
subWeaponIds: number[];
|
||||
specialWeaponIds: number[];
|
||||
targets: DamageMultiplierWithHistory[];
|
||||
}
|
||||
|
||||
const DAMAGE_RECEIVER_ORDER = new Map(
|
||||
DAMAGE_RECEIVERS.map((receiver, i) => [receiver as string, i]),
|
||||
);
|
||||
|
||||
const EMPTY_ATTACKERS: IncomingDamageAttackers = {
|
||||
mainWeaponIds: [],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
};
|
||||
|
||||
/** Whether `candidate` is a better single representative of a target than the `current` pick. */
|
||||
function isMoreInformativeMultiplier(
|
||||
candidate: DamageMultiplierWithHistory,
|
||||
current: DamageMultiplierWithHistory,
|
||||
): boolean {
|
||||
if (candidate.history.length !== current.history.length) {
|
||||
return candidate.history.length > current.history.length;
|
||||
}
|
||||
return candidate.current > current.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the damage multiplier history of every damage rate row that applies to the given
|
||||
* weapon, reduced to a single entry per object target. A weapon can map to several rows (e.g.
|
||||
* different attacks) that share the same target; the most informative one (longest tracked
|
||||
* history, then highest current rate) is kept. Entries are ordered like {@link DAMAGE_RECEIVERS}.
|
||||
*/
|
||||
export function damageMultipliersForWeapon(
|
||||
rows: Record<string, DamageRateHistoryRow>,
|
||||
weaponId: number,
|
||||
kind: WeaponParamKind,
|
||||
): DamageMultiplierWithHistory[] {
|
||||
const applies = (row: DamageRateHistoryRow) => {
|
||||
if (kind === "sub") return row.subWeaponIds.includes(weaponId);
|
||||
if (kind === "special") return row.specialWeaponIds.includes(weaponId);
|
||||
return (
|
||||
row.mainWeaponIds.includes(weaponId) ||
|
||||
row.mainWeaponIds.includes(
|
||||
weaponIdToBaseWeaponId(weaponId as MainWeaponId),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const byTarget = new Map<string, DamageMultiplierWithHistory>();
|
||||
|
||||
for (const row of Object.values(rows)) {
|
||||
if (!applies(row)) continue;
|
||||
for (const target of row.targets) {
|
||||
const existing = byTarget.get(target.target);
|
||||
if (!existing || isMoreInformativeMultiplier(target, existing)) {
|
||||
byTarget.set(target.target, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byTarget.values()].sort(
|
||||
(a, b) =>
|
||||
(DAMAGE_RECEIVER_ORDER.get(a.target) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(DAMAGE_RECEIVER_ORDER.get(b.target) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable identifier for a group of attacking weapons, used to de-duplicate incoming entries. */
|
||||
function attackerGroupKey(attackers: IncomingDamageAttackers): string {
|
||||
const part = (ids: number[]) => [...ids].sort((a, b) => a - b).join(",");
|
||||
return `m${part(attackers.mainWeaponIds)};s${part(attackers.subWeaponIds)};x${part(attackers.specialWeaponIds)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects, for the given sub or special weapon (which must itself be a damageable object), the
|
||||
* history of every *other* weapon's damage multiplier against it. Each entry is one group of
|
||||
* attacking weapons that shared a rate change against one of the weapon's receiver targets; per
|
||||
* (attacker group, target) the most informative entry (longest history, then highest rate) is
|
||||
* kept. Entries are ordered like {@link DAMAGE_RECEIVERS}, then by attacker group.
|
||||
*/
|
||||
export function incomingDamageMultipliersForWeapon(
|
||||
rows: Record<string, DamageRateHistoryRow>,
|
||||
weaponId: number,
|
||||
kind: "sub" | "special",
|
||||
): IncomingDamageMultiplierWithHistory[] {
|
||||
const receiverTargets = INCOMING_DAMAGE_RECEIVERS[kind][weaponId];
|
||||
if (!receiverTargets) return [];
|
||||
const targetSet = new Set<string>(receiverTargets);
|
||||
|
||||
const byKey = new Map<string, IncomingDamageMultiplierWithHistory>();
|
||||
|
||||
for (const row of Object.values(rows)) {
|
||||
const attackers: IncomingDamageAttackers = {
|
||||
mainWeaponIds:
|
||||
row.mainWeaponIds as IncomingDamageAttackers["mainWeaponIds"],
|
||||
subWeaponIds: row.subWeaponIds as IncomingDamageAttackers["subWeaponIds"],
|
||||
specialWeaponIds:
|
||||
row.specialWeaponIds as IncomingDamageAttackers["specialWeaponIds"],
|
||||
};
|
||||
const attackerKey = attackerGroupKey(attackers);
|
||||
|
||||
for (const target of row.targets) {
|
||||
if (!targetSet.has(target.target)) continue;
|
||||
|
||||
const key = `${attackerKey}|${target.target}`;
|
||||
const existing = byKey.get(key);
|
||||
if (!existing || isMoreInformativeMultiplier(target, existing)) {
|
||||
byKey.set(key, {
|
||||
target: target.target,
|
||||
attackers,
|
||||
current: target.current,
|
||||
history: target.history,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byKey.values()].sort((a, b) => {
|
||||
const order =
|
||||
(DAMAGE_RECEIVER_ORDER.get(a.target) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(DAMAGE_RECEIVER_ORDER.get(b.target) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (order !== 0) return order;
|
||||
return attackerGroupKey(a.attackers).localeCompare(
|
||||
attackerGroupKey(b.attackers),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function changesFromHistory(
|
||||
history: Array<{ version: string; value: number | string }>,
|
||||
current: number | string,
|
||||
versions: string[],
|
||||
versionIndex: Map<string, number>,
|
||||
): Array<{ patchVersion: string; from: number | string; to: number | string }> {
|
||||
const result: Array<{
|
||||
patchVersion: string;
|
||||
from: number | string;
|
||||
to: number | string;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const { version, value: from } = history[i];
|
||||
const to = i < history.length - 1 ? history[i + 1].value : current;
|
||||
|
||||
// A recorded value is the value *before* a change, so the change took effect at the
|
||||
// next tracked game version.
|
||||
const recordedIndex = versionIndex.get(version);
|
||||
if (recordedIndex === undefined) continue;
|
||||
const patchVersion = versions[recordedIndex + 1];
|
||||
if (!patchVersion) continue;
|
||||
|
||||
result.push({ patchVersion, from, to });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups every tracked parameter change of a single weapon by the game version (patch) that
|
||||
* introduced it. Optionally folds the weapon's special points history into the same grouping.
|
||||
*
|
||||
* Within each patch the changes are sorted with special points first, then alphabetically by
|
||||
* category and key.
|
||||
*/
|
||||
function computeWeaponPatchChanges(
|
||||
parsed: ParsedWeaponParams,
|
||||
versions: string[],
|
||||
specialPoints?: SpecialPointWithHistory[],
|
||||
damageMultipliers?: DamageMultiplierWithHistory[],
|
||||
source?: WeaponParamKind,
|
||||
incomingDamageMultipliers?: IncomingDamageMultiplierWithHistory[],
|
||||
): Map<string, PatchChange[]> {
|
||||
const versionIndex = new Map(versions.map((version, i) => [version, i]));
|
||||
const byVersion = new Map<string, PatchChange[]>();
|
||||
|
||||
const push = (patchVersion: string, change: PatchChange) => {
|
||||
const existing = byVersion.get(patchVersion);
|
||||
if (existing) {
|
||||
existing.push(change);
|
||||
} else {
|
||||
byVersion.set(patchVersion, [change]);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [category, params] of Object.entries(parsed.categories)) {
|
||||
for (const [key, param] of Object.entries(params)) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
param.history,
|
||||
param.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
push(patchVersion, {
|
||||
category,
|
||||
key,
|
||||
from,
|
||||
to,
|
||||
kind: classifyParamChange(category, key, from, to),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const kit of specialPoints ?? []) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
kit.history,
|
||||
kit.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
// Fewer special points needed means the special charges faster.
|
||||
const kind = from === to ? "neutral" : to < from ? "buff" : "nerf";
|
||||
push(patchVersion, {
|
||||
category: SPECIAL_POINTS_PARAM_KEY,
|
||||
key: SPECIAL_POINTS_PARAM_KEY,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
weaponId: kit.weaponId,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const multiplier of damageMultipliers ?? []) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
multiplier.history,
|
||||
multiplier.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
// A higher damage rate means the weapon deals more damage to the object.
|
||||
const kind = from === to ? "neutral" : to > from ? "buff" : "nerf";
|
||||
push(patchVersion, {
|
||||
category: DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: multiplier.target,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const multiplier of incomingDamageMultipliers ?? []) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
multiplier.history,
|
||||
multiplier.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
// A higher incoming damage rate means the object takes more damage, i.e. a nerf to the
|
||||
// sub or special weapon being defended (the inverse of an outgoing damage multiplier).
|
||||
const kind = from === to ? "neutral" : to > from ? "nerf" : "buff";
|
||||
push(patchVersion, {
|
||||
category: INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: multiplier.target,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
source,
|
||||
attackers: multiplier.attackers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const changes of byVersion.values()) {
|
||||
changes.sort((a, b) => {
|
||||
// Special points first (ordered by kit), then outgoing damage multipliers, then
|
||||
// incoming damage multipliers, then regular params by category and key.
|
||||
const rank = (change: PatchChange) =>
|
||||
change.category === SPECIAL_POINTS_PARAM_KEY
|
||||
? 0
|
||||
: change.category === DAMAGE_MULTIPLIER_PARAM_KEY
|
||||
? 1
|
||||
: change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY
|
||||
? 2
|
||||
: 3;
|
||||
const aRank = rank(a);
|
||||
const bRank = rank(b);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
|
||||
if (aRank === 0) return (a.weaponId ?? 0) - (b.weaponId ?? 0);
|
||||
if (aRank === 2) {
|
||||
const order =
|
||||
(DAMAGE_RECEIVER_ORDER.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(DAMAGE_RECEIVER_ORDER.get(b.key) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (order !== 0) return order;
|
||||
return attackerGroupKey(a.attackers ?? EMPTY_ATTACKERS).localeCompare(
|
||||
attackerGroupKey(b.attackers ?? EMPTY_ATTACKERS),
|
||||
);
|
||||
}
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
}
|
||||
|
||||
return byVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles per-version change maps into the descending-by-version patch history, attaching each
|
||||
* tracked game version's release date and skipping versions with no changes. When several maps are
|
||||
* given (e.g. a kit's main, sub and special weapon changes) their changes are concatenated in the
|
||||
* order the maps are passed, keeping each map's own within-version ordering.
|
||||
*/
|
||||
function changeMapsToPatches(
|
||||
maps: Array<Map<string, PatchChange[]>>,
|
||||
versions: string[],
|
||||
): WeaponPatch[] {
|
||||
const patchDateByVersion = new Map(PATCHES.map((p) => [p.patch, p.date]));
|
||||
|
||||
return versions
|
||||
.map((version) => ({
|
||||
version,
|
||||
date: patchDateByVersion.get(version) ?? null,
|
||||
changes: maps.flatMap((map) => map.get(version) ?? []),
|
||||
}))
|
||||
.filter((patch) => patch.changes.length > 0)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the descending-by-version patch history of a single weapon, attaching each tracked
|
||||
* game version's release date and skipping versions with no tracked balance changes. Special
|
||||
* points changes are only folded in for main weapons (pass their history as `specialPoints`).
|
||||
*/
|
||||
export function patchHistory(
|
||||
parsed: ParsedWeaponParams | undefined,
|
||||
versions: string[],
|
||||
specialPoints: SpecialPointWithHistory[] = [],
|
||||
damageMultipliers: DamageMultiplierWithHistory[] = [],
|
||||
incomingDamageMultipliers: IncomingDamageMultiplierWithHistory[] = [],
|
||||
): WeaponPatch[] {
|
||||
if (!parsed) return [];
|
||||
|
||||
return changeMapsToPatches(
|
||||
[
|
||||
computeWeaponPatchChanges(
|
||||
parsed,
|
||||
versions,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
undefined,
|
||||
incomingDamageMultipliers,
|
||||
),
|
||||
],
|
||||
versions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a patch history per kit of a main weapon, folding the (shared) main weapon changes
|
||||
* together with the kit's own special points, sub weapon and special weapon changes. Every change
|
||||
* is tagged with its `source` so the patch history can group a column under a divider per weapon.
|
||||
*/
|
||||
export function kitPatchHistories({
|
||||
mainParsed,
|
||||
versions,
|
||||
kits,
|
||||
specialPointsByKit,
|
||||
mainDamageMultipliers,
|
||||
subParams,
|
||||
subDamageMultipliers,
|
||||
subIncomingDamageMultipliers,
|
||||
specialParams,
|
||||
specialDamageMultipliers,
|
||||
specialIncomingDamageMultipliers,
|
||||
}: {
|
||||
mainParsed: ParsedWeaponParams | undefined;
|
||||
versions: string[];
|
||||
kits: WeaponKitInfo[];
|
||||
specialPointsByKit: Record<string, SpecialPointWithHistory>;
|
||||
mainDamageMultipliers: DamageMultiplierWithHistory[];
|
||||
subParams: Record<string, ParsedWeaponParams | undefined>;
|
||||
subDamageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
|
||||
subIncomingDamageMultipliers: Record<
|
||||
string,
|
||||
IncomingDamageMultiplierWithHistory[]
|
||||
>;
|
||||
specialParams: Record<string, ParsedWeaponParams | undefined>;
|
||||
specialDamageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
|
||||
specialIncomingDamageMultipliers: Record<
|
||||
string,
|
||||
IncomingDamageMultiplierWithHistory[]
|
||||
>;
|
||||
}): KitPatchHistory[] {
|
||||
if (!mainParsed) return [];
|
||||
|
||||
return kits.map((kit) => {
|
||||
const kitSpecialPoints = specialPointsByKit[String(kit.weaponId)];
|
||||
const maps = [
|
||||
computeWeaponPatchChanges(
|
||||
mainParsed,
|
||||
versions,
|
||||
kitSpecialPoints ? [kitSpecialPoints] : [],
|
||||
mainDamageMultipliers,
|
||||
"main",
|
||||
),
|
||||
];
|
||||
|
||||
const subIncoming =
|
||||
subIncomingDamageMultipliers[String(kit.subWeaponId)] ?? [];
|
||||
const subParsed = subParams[String(kit.subWeaponId)];
|
||||
if (subParsed || subIncoming.length > 0) {
|
||||
maps.push(
|
||||
computeWeaponPatchChanges(
|
||||
subParsed ?? { weaponId: kit.subWeaponId, categories: {} },
|
||||
versions,
|
||||
[],
|
||||
subDamageMultipliers[String(kit.subWeaponId)] ?? [],
|
||||
"sub",
|
||||
subIncoming,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const specialIncoming =
|
||||
specialIncomingDamageMultipliers[String(kit.specialWeaponId)] ?? [];
|
||||
const specialParsed = specialParams[String(kit.specialWeaponId)];
|
||||
if (specialParsed || specialIncoming.length > 0) {
|
||||
maps.push(
|
||||
computeWeaponPatchChanges(
|
||||
specialParsed ?? { weaponId: kit.specialWeaponId, categories: {} },
|
||||
versions,
|
||||
[],
|
||||
specialDamageMultipliers[String(kit.specialWeaponId)] ?? [],
|
||||
"special",
|
||||
specialIncoming,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
weaponId: kit.weaponId,
|
||||
subWeaponId: kit.subWeaponId,
|
||||
specialWeaponId: kit.specialWeaponId,
|
||||
patches: changeMapsToPatches(maps, versions),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Formats a parameter value for display, trimming trailing zeroes from non-integer numbers. */
|
||||
export function formatValue(value: number | string): string {
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return value.toFixed(4).replace(/\.?0+$/, "");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
186
app/features/params/core/param-directions.ts
Normal file
186
app/features/params/core/param-directions.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Whether a higher value is better ("higher") or worse ("lower") for the player who owns
|
||||
* the weapon. `null` means the direction is unknown / context-dependent.
|
||||
*/
|
||||
type ParamDirection = "higher" | "lower" | null;
|
||||
|
||||
/**
|
||||
* How a value change between two patches affected the weapon: a `"buff"` made it stronger,
|
||||
* a `"nerf"` made it weaker, and `"neutral"` is either an unclassified parameter or a change
|
||||
* whose impact direction we don't track.
|
||||
*/
|
||||
export type ParamChangeKind = "buff" | "nerf" | "neutral";
|
||||
|
||||
/**
|
||||
* Ordered substring rules describing whether a higher value of a parameter is good for its
|
||||
* weapon. The first rule whose `match` is a substring of the full `${category}.${key}` wins,
|
||||
* so narrower exceptions are listed before broader rules (e.g. `ReceiveDamage` before
|
||||
* `Damage`). Parameters matching no rule are treated as having an unknown direction.
|
||||
*/
|
||||
const PARAM_DIRECTION_RULES: Array<{
|
||||
match: string;
|
||||
betterWhenHigher: boolean;
|
||||
}> = [
|
||||
// Taking less damage is good, so these override the broader "Damage" rule below.
|
||||
{ match: "ReceiveDamage", betterWhenHigher: false },
|
||||
{ match: "AttackedDamageRate", betterWhenHigher: false },
|
||||
|
||||
// Lower is better: less ink, faster recovery, tighter spread, shorter delays.
|
||||
{ match: "InkConsume", betterWhenHigher: false },
|
||||
{ match: "InkRecoverStop", betterWhenHigher: false },
|
||||
{ match: "DegSwerve", betterWhenHigher: false },
|
||||
{ match: "DegBias", betterWhenHigher: false },
|
||||
{ match: "ChargeFrame", betterWhenHigher: false },
|
||||
{ match: "RepeatFrame", betterWhenHigher: false },
|
||||
{ match: "PostDelayFrame", betterWhenHigher: false },
|
||||
{ match: "PreDelayFrame", betterWhenHigher: false },
|
||||
{ match: "DashFrame", betterWhenHigher: false },
|
||||
{ match: "NakedFrame", betterWhenHigher: false },
|
||||
{ match: "Dash_ChargeCancelableFrame", betterWhenHigher: false },
|
||||
|
||||
// Higher is better: more damage, durability, mobility, paint, range, uptime.
|
||||
{ match: "Damage", betterWhenHigher: true },
|
||||
{ match: "CanopyHP", betterWhenHigher: true },
|
||||
{ match: "ArmorHP", betterWhenHigher: true },
|
||||
{ match: "MaxFieldHP", betterWhenHigher: true },
|
||||
{ match: "MaxHP", betterWhenHigher: true },
|
||||
{ match: "HitPoint", betterWhenHigher: true },
|
||||
{ match: "MoveSpeed", betterWhenHigher: true },
|
||||
{ match: "WidthHalf", betterWhenHigher: true },
|
||||
{ match: "PaintRadius", betterWhenHigher: true },
|
||||
{ match: "CrossPaint", betterWhenHigher: true },
|
||||
{ match: "PaintHeight", betterWhenHigher: true },
|
||||
{ match: "SpawnNum", betterWhenHigher: true },
|
||||
{ match: "SplitNum", betterWhenHigher: true },
|
||||
{ match: "SpawnSpeed", betterWhenHigher: true },
|
||||
{ match: "GoStraightStateEndMaxSpeed", betterWhenHigher: true },
|
||||
{ match: "MaxShootingFrame", betterWhenHigher: true },
|
||||
{ match: "ServeAreaRadius", betterWhenHigher: true },
|
||||
{ match: "PowerUpFrame", betterWhenHigher: true },
|
||||
{ match: "KnockBackParam.Distance", betterWhenHigher: true },
|
||||
|
||||
// Longer-lasting effects and uptime are buffs.
|
||||
{ match: "SpecialTotalFrame", betterWhenHigher: true },
|
||||
{ match: "SpecialDurationFrame", betterWhenHigher: true },
|
||||
{ match: "MarkingFrame", betterWhenHigher: true },
|
||||
{ match: "RainyFrame", betterWhenHigher: true },
|
||||
{ match: "LaserFrame", betterWhenHigher: true },
|
||||
{ match: ".Low", betterWhenHigher: true },
|
||||
{ match: ".Mid", betterWhenHigher: true },
|
||||
{ match: ".High", betterWhenHigher: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns whether a higher value of the given parameter benefits the weapon's owner, using
|
||||
* substring matching against `${category}.${key}`. Returns `null` when the parameter is not
|
||||
* recognized as clearly directional.
|
||||
*/
|
||||
function getParamDirection(category: string, key: string): ParamDirection {
|
||||
const fullKey = `${category}.${key}`;
|
||||
|
||||
for (const { match, betterWhenHigher } of PARAM_DIRECTION_RULES) {
|
||||
if (fullKey.includes(match)) {
|
||||
return betterWhenHigher ? "higher" : "lower";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Matches a single `"<damage> @ <distance>"` breakpoint of a serialized damage falloff curve. */
|
||||
const DAMAGE_BREAKPOINT_PATTERN = /^\s*([\d.]+)\s*@\s*([\d.]+)\s*$/;
|
||||
|
||||
/**
|
||||
* Parses a serialized damage falloff curve (see `formatDistanceDamageArray`) back into its
|
||||
* breakpoints. Returns `null` for any other string (enums, primitive-array blobs), which are
|
||||
* treated as non-directional.
|
||||
*/
|
||||
function parseDamageCurve(
|
||||
value: number | string,
|
||||
): Array<{ damage: number; distance: number }> | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const breakpoints: Array<{ damage: number; distance: number }> = [];
|
||||
for (const part of value.split(",")) {
|
||||
const match = part.match(DAMAGE_BREAKPOINT_PATTERN);
|
||||
if (!match) return null;
|
||||
breakpoints.push({ damage: Number(match[1]), distance: Number(match[2]) });
|
||||
}
|
||||
|
||||
return breakpoints.length > 0 ? breakpoints : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a change between two damage falloff curves by comparing them breakpoint by
|
||||
* breakpoint. Both more damage and more reach (a higher distance at which a damage tier still
|
||||
* applies) count as improvements, so a curve where every change improves is a buff, every change
|
||||
* worsens is a nerf, and a mix (or curves of differing shape) is neutral. Returns `null` when the
|
||||
* values are not both damage curves, so the caller falls back to scalar comparison.
|
||||
*/
|
||||
function classifyDamageCurveChange(
|
||||
direction: ParamDirection,
|
||||
from: number | string,
|
||||
to: number | string,
|
||||
): ParamChangeKind | null {
|
||||
const fromCurve = parseDamageCurve(from);
|
||||
const toCurve = parseDamageCurve(to);
|
||||
if (!fromCurve || !toCurve || fromCurve.length !== toCurve.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let improved = false;
|
||||
let worsened = false;
|
||||
for (let i = 0; i < fromCurve.length; i++) {
|
||||
for (const field of ["damage", "distance"] as const) {
|
||||
const before = fromCurve[i][field];
|
||||
const after = toCurve[i][field];
|
||||
if (before === after) continue;
|
||||
const isImprovement =
|
||||
direction === "lower" ? after < before : after > before;
|
||||
if (isImprovement) {
|
||||
improved = true;
|
||||
} else {
|
||||
worsened = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (improved && !worsened) return "buff";
|
||||
if (worsened && !improved) return "nerf";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a parameter value change between two patches as a buff, a nerf, or neutral.
|
||||
*
|
||||
* Damage falloff curves are compared breakpoint by breakpoint (see
|
||||
* {@link classifyDamageCurveChange}). Neutral is returned for other non-numeric values, unchanged
|
||||
* values, or parameters whose impact direction is unknown (see {@link getParamDirection}).
|
||||
*/
|
||||
export function classifyParamChange(
|
||||
category: string,
|
||||
key: string,
|
||||
from: number | string,
|
||||
to: number | string,
|
||||
): ParamChangeKind {
|
||||
const direction = getParamDirection(category, key);
|
||||
if (direction === null) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const curveChange = classifyDamageCurveChange(direction, from, to);
|
||||
if (curveChange !== null) {
|
||||
return curveChange;
|
||||
}
|
||||
|
||||
if (typeof from !== "number" || typeof to !== "number" || from === to) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const increased = to > from;
|
||||
const improved = direction === "higher" ? increased : !increased;
|
||||
|
||||
return improved ? "buff" : "nerf";
|
||||
}
|
||||
2115
app/features/params/core/param-explanations.ts
Normal file
2115
app/features/params/core/param-explanations.ts
Normal file
File diff suppressed because it is too large
Load Diff
2169
app/features/params/data/all-version-special-params.json
Normal file
2169
app/features/params/data/all-version-special-params.json
Normal file
File diff suppressed because it is too large
Load Diff
1052
app/features/params/data/all-version-sub-params.json
Normal file
1052
app/features/params/data/all-version-sub-params.json
Normal file
File diff suppressed because it is too large
Load Diff
27242
app/features/params/data/all-version-weapon-params.json
Normal file
27242
app/features/params/data/all-version-weapon-params.json
Normal file
File diff suppressed because it is too large
Load Diff
837
app/features/params/data/damage-rate-history.json
Normal file
837
app/features/params/data/damage-rate-history.json
Normal file
@@ -0,0 +1,837 @@
|
||||
{
|
||||
"metadata": {
|
||||
"versions": [
|
||||
"0.9.9",
|
||||
"1.0.0",
|
||||
"1.1.0",
|
||||
"1.1.1",
|
||||
"1.2.0",
|
||||
"2.0.0",
|
||||
"2.1.0",
|
||||
"3.0.0",
|
||||
"3.1.0",
|
||||
"4.0.0",
|
||||
"4.1.0",
|
||||
"5.0.0",
|
||||
"5.1.0",
|
||||
"5.2.0",
|
||||
"6.0.0",
|
||||
"6.1.0",
|
||||
"7.0.0",
|
||||
"7.1.0",
|
||||
"7.2.0",
|
||||
"8.0.0",
|
||||
"8.1.0",
|
||||
"9.0.0",
|
||||
"9.1.0",
|
||||
"9.2.0",
|
||||
"9.3.0",
|
||||
"10.0.0",
|
||||
"10.1.0",
|
||||
"11.0.0",
|
||||
"11.0.1",
|
||||
"11.1.0",
|
||||
"11.2.0"
|
||||
]
|
||||
},
|
||||
"rows": {
|
||||
"Blaster_BlasterShort": {
|
||||
"mainWeaponIds": [200, 201, 205],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.2,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BlowerExhale_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [8],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 4.2,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 5.6
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 5.6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 2.1,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 2.8
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Bomb_DirectHit": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [2, 7, 0],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 1.5
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Bomb_TorpedoSplashBurst": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [13],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 0.25
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BrushSplash": {
|
||||
"mainWeaponIds": [1100, 1101, 1110, 1111, 1112, 1115],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 1.98,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 1.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Castle": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [17],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 2.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 2.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Jetpack_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [10],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.8
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.365,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.575
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.05
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Jetpack_Bullet": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [10],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 2.0835,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 0.8334
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.8
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.365,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.575
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.05
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"MultiMissile_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [4],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 0.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 0.375,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 0.75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"MultiMissile_Bullet": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [4],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 0.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 0.375,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 0.75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"NiceBall": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [6],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 4,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 2,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"version": "9.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 2,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"version": "9.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RollerSplash_Compact": {
|
||||
"mainWeaponIds": [1000, 1001, 1002],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.64,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 2.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RollerSplash": {
|
||||
"mainWeaponIds": [1010, 1011, 1015],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.64,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 2.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_ChargeShot": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 2.6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "ShockSonar",
|
||||
"current": 2.4,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 4.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_ChargeSlash": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 0.455,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 0.65
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_Shot": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 2.6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "ShockSonar",
|
||||
"current": 2.4,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 4.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_Slash": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 0.91,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"TripleTornado": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [14],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraShot": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [1],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 1.8,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.3,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.5
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 0.975,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 1.5
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.125
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 0.75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraStamp_Swing": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [11],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 4.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraStamp_Throw_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [11],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Bomb_TorpedoBullet",
|
||||
"current": 0.545,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyCompact",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyNormal",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyNormal_Launched",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletShelterCanopyFocus",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletShelterCanopyFocus_Launched",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyWide",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyWide_Launched",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3.273,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Gachihoko_Barrier",
|
||||
"current": 1.909,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.909,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.4318,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 2.625
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 1.364,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 2.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "ShockSonar",
|
||||
"current": 2.182,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Wsb_Flag",
|
||||
"current": 1.636,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.182,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Wsb_Sprinkler",
|
||||
"current": 1.636,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraStamp_Throw": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [11],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3.273,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.091
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
306
app/features/params/loaders/params.$slug.server.ts
Normal file
306
app/features/params/loaders/params.$slug.server.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponIdToBaseWeaponId,
|
||||
weaponIdToType,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
specialWeaponNameSlugToId,
|
||||
subWeaponNameSlugToId,
|
||||
weaponNameSlugToId,
|
||||
} from "~/utils/unslugify.server";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import specialWeaponParamsData from "../data/all-version-special-params.json";
|
||||
import subWeaponParamsData from "../data/all-version-sub-params.json";
|
||||
import weaponParamsData from "../data/all-version-weapon-params.json";
|
||||
import damageRateHistoryData from "../data/damage-rate-history.json";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
IncomingDamageMultiplierWithHistory,
|
||||
SpecialPointWithHistory,
|
||||
WeaponParamKind,
|
||||
} from "../weapon-params-types";
|
||||
|
||||
const mainParamsData = weaponParamsData as WeaponParams.AllVersionParams;
|
||||
const subParamsData = subWeaponParamsData as WeaponParams.AllVersionParams;
|
||||
const specialParamsData =
|
||||
specialWeaponParamsData as WeaponParams.AllVersionParams;
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["weapons"], {
|
||||
lng: "en",
|
||||
});
|
||||
|
||||
const mainWeaponId = weaponNameSlugToId(params.slug);
|
||||
if (typeof mainWeaponId === "number") {
|
||||
if (weaponIdToType(mainWeaponId) === "ALT_SKIN") {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
return mainWeaponData(
|
||||
mainWeaponId,
|
||||
t(`weapons:MAIN_${mainWeaponId}`),
|
||||
mySlugify(t(`weapons:MAIN_${mainWeaponId}`, { lng: "en" })),
|
||||
);
|
||||
}
|
||||
|
||||
const subWeaponId = subWeaponNameSlugToId(params.slug);
|
||||
if (typeof subWeaponId === "number") {
|
||||
return subWeaponData(
|
||||
subWeaponId,
|
||||
t(`weapons:SUB_${subWeaponId}`),
|
||||
mySlugify(t(`weapons:SUB_${subWeaponId}`, { lng: "en" })),
|
||||
);
|
||||
}
|
||||
|
||||
const specialWeaponId = specialWeaponNameSlugToId(params.slug);
|
||||
if (typeof specialWeaponId === "number") {
|
||||
return specialWeaponData(
|
||||
specialWeaponId,
|
||||
t(`weapons:SPECIAL_${specialWeaponId}`),
|
||||
mySlugify(t(`weapons:SPECIAL_${specialWeaponId}`, { lng: "en" })),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Response(null, { status: 404 });
|
||||
};
|
||||
|
||||
function damageMultipliersByWeapon(
|
||||
weaponIds: number[],
|
||||
kind: WeaponParamKind,
|
||||
): Record<string, DamageMultiplierWithHistory[]> {
|
||||
const rows = damageRateHistoryData.rows as Parameters<
|
||||
typeof WeaponParams.damageMultipliersForWeapon
|
||||
>[0];
|
||||
|
||||
const result: Record<string, DamageMultiplierWithHistory[]> = {};
|
||||
for (const id of weaponIds) {
|
||||
const multipliers = WeaponParams.damageMultipliersForWeapon(rows, id, kind);
|
||||
if (multipliers.length > 0) {
|
||||
result[String(id)] = multipliers;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function incomingDamageMultipliersByWeapon(
|
||||
weaponIds: number[],
|
||||
kind: "sub" | "special",
|
||||
): Record<string, IncomingDamageMultiplierWithHistory[]> {
|
||||
const rows = damageRateHistoryData.rows as Parameters<
|
||||
typeof WeaponParams.incomingDamageMultipliersForWeapon
|
||||
>[0];
|
||||
|
||||
const result: Record<string, IncomingDamageMultiplierWithHistory[]> = {};
|
||||
for (const id of weaponIds) {
|
||||
const multipliers = WeaponParams.incomingDamageMultipliersForWeapon(
|
||||
rows,
|
||||
id,
|
||||
kind,
|
||||
);
|
||||
if (multipliers.length > 0) {
|
||||
result[String(id)] = multipliers;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mainWeaponData(
|
||||
weaponId: MainWeaponId,
|
||||
weaponName: string,
|
||||
slug: string,
|
||||
) {
|
||||
const categoryWeaponIds = WeaponParams.categoryWeaponIds(weaponId);
|
||||
|
||||
const kits = WeaponParams.kitSiblingIds(weaponId).map((id) => {
|
||||
const { subWeaponId, specialWeaponId } = mainWeaponParams(id);
|
||||
return { weaponId: id, subWeaponId, specialWeaponId };
|
||||
});
|
||||
|
||||
const versions = mainParamsData.metadata.versions;
|
||||
|
||||
const weaponParams = WeaponParams.parseMany(
|
||||
categoryWeaponIds,
|
||||
mainParamsData,
|
||||
weaponIdToBaseWeaponId,
|
||||
);
|
||||
|
||||
const allSpecialPoints = mainParamsData.specialPoints;
|
||||
|
||||
const specialPoints: Record<string, SpecialPointWithHistory[]> = {};
|
||||
for (const id of categoryWeaponIds) {
|
||||
specialPoints[String(id)] = WeaponParams.kitSiblingIds(id).map((kitId) => ({
|
||||
weaponId: kitId,
|
||||
current: mainWeaponParams(kitId).SpecialPoint,
|
||||
history: allSpecialPoints?.[String(kitId)]?.history ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
const damageMultipliers = damageMultipliersByWeapon(
|
||||
categoryWeaponIds,
|
||||
"main",
|
||||
);
|
||||
|
||||
const patchHistory = WeaponParams.patchHistory(
|
||||
weaponParams[String(weaponId)],
|
||||
versions,
|
||||
specialPoints[String(weaponId)] ?? [],
|
||||
damageMultipliers[String(weaponId)] ?? [],
|
||||
);
|
||||
|
||||
const subParams = WeaponParams.parseMany(
|
||||
kits.map((kit) => kit.subWeaponId),
|
||||
subParamsData,
|
||||
);
|
||||
|
||||
const specialParams = WeaponParams.parseMany(
|
||||
kits.map((kit) => kit.specialWeaponId),
|
||||
specialParamsData,
|
||||
);
|
||||
|
||||
const specialPointsByKit: Record<string, SpecialPointWithHistory> = {};
|
||||
for (const { weaponId: kitId } of kits) {
|
||||
specialPointsByKit[String(kitId)] = {
|
||||
weaponId: kitId,
|
||||
current: mainWeaponParams(kitId).SpecialPoint,
|
||||
history: allSpecialPoints?.[String(kitId)]?.history ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const kitPatchHistories = WeaponParams.kitPatchHistories({
|
||||
mainParsed: weaponParams[String(weaponId)],
|
||||
versions,
|
||||
kits,
|
||||
specialPointsByKit,
|
||||
mainDamageMultipliers: damageMultipliers[String(weaponId)] ?? [],
|
||||
subParams,
|
||||
subDamageMultipliers: damageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.subWeaponId),
|
||||
"sub",
|
||||
),
|
||||
subIncomingDamageMultipliers: incomingDamageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.subWeaponId),
|
||||
"sub",
|
||||
),
|
||||
specialParams,
|
||||
specialDamageMultipliers: damageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.specialWeaponId),
|
||||
"special",
|
||||
),
|
||||
specialIncomingDamageMultipliers: incomingDamageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.specialWeaponId),
|
||||
"special",
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "main" as WeaponParamKind,
|
||||
weaponId,
|
||||
weaponName,
|
||||
slug,
|
||||
categoryWeaponIds,
|
||||
kits,
|
||||
weaponParams,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
patchHistory,
|
||||
kitPatchHistories,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
function subWeaponData(
|
||||
weaponId: SubWeaponId,
|
||||
weaponName: string,
|
||||
slug: string,
|
||||
) {
|
||||
const versions = subParamsData.metadata.versions;
|
||||
|
||||
const weaponParams = WeaponParams.parseMany(subWeaponIds, subParamsData);
|
||||
|
||||
const damageMultipliers = damageMultipliersByWeapon([...subWeaponIds], "sub");
|
||||
|
||||
const incomingDamageMultipliers = incomingDamageMultipliersByWeapon(
|
||||
[...subWeaponIds],
|
||||
"sub",
|
||||
);
|
||||
|
||||
const patchHistory = WeaponParams.patchHistory(
|
||||
weaponParams[String(weaponId)],
|
||||
versions,
|
||||
[],
|
||||
damageMultipliers[String(weaponId)] ?? [],
|
||||
incomingDamageMultipliers[String(weaponId)] ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "sub" as WeaponParamKind,
|
||||
weaponId,
|
||||
weaponName,
|
||||
slug,
|
||||
categoryWeaponIds: [...subWeaponIds],
|
||||
kits: undefined,
|
||||
weaponParams,
|
||||
specialPoints: undefined,
|
||||
damageMultipliers,
|
||||
patchHistory,
|
||||
kitPatchHistories: undefined,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
function specialWeaponData(
|
||||
weaponId: SpecialWeaponId,
|
||||
weaponName: string,
|
||||
slug: string,
|
||||
) {
|
||||
const versions = specialParamsData.metadata.versions;
|
||||
|
||||
const weaponParams = WeaponParams.parseMany(
|
||||
specialWeaponIds,
|
||||
specialParamsData,
|
||||
);
|
||||
|
||||
const damageMultipliers = damageMultipliersByWeapon(
|
||||
[...specialWeaponIds],
|
||||
"special",
|
||||
);
|
||||
|
||||
const incomingDamageMultipliers = incomingDamageMultipliersByWeapon(
|
||||
[...specialWeaponIds],
|
||||
"special",
|
||||
);
|
||||
|
||||
const patchHistory = WeaponParams.patchHistory(
|
||||
weaponParams[String(weaponId)],
|
||||
versions,
|
||||
[],
|
||||
damageMultipliers[String(weaponId)] ?? [],
|
||||
incomingDamageMultipliers[String(weaponId)] ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "special" as WeaponParamKind,
|
||||
weaponId,
|
||||
weaponName,
|
||||
slug,
|
||||
categoryWeaponIds: [...specialWeaponIds],
|
||||
kits: undefined,
|
||||
weaponParams,
|
||||
specialPoints: undefined,
|
||||
damageMultipliers,
|
||||
patchHistory,
|
||||
kitPatchHistories: undefined,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
78
app/features/params/routes/params.$slug.tsx
Normal file
78
app/features/params/routes/params.$slug.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
outlinedMainWeaponImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
weaponParamsPage,
|
||||
} from "~/utils/urls";
|
||||
import { WeaponParamsView } from "../components/WeaponParamsView";
|
||||
import { loader } from "../loaders/params.$slug.server";
|
||||
import type { WeaponParamKind } from "../weapon-params-types";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["weapons", "common", "analyzer", "params"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.data as SerializeFrom<typeof loader> | undefined;
|
||||
if (!data) return [];
|
||||
return [
|
||||
{
|
||||
imgPath: weaponImageUrl(data.kind, data.weaponId),
|
||||
href: weaponParamsPage(data.slug),
|
||||
type: "IMAGE",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (args) => {
|
||||
if (!args.data) return [];
|
||||
return metaTags({
|
||||
title: `${args.data.weaponName} parameters`,
|
||||
description: `${args.data.weaponName} parameters with version history compared across ${comparedAcross(args.data.kind)}.`,
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function WeaponParamsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<WeaponParamsView
|
||||
kind={data.kind}
|
||||
weaponId={data.weaponId}
|
||||
categoryWeaponIds={data.categoryWeaponIds}
|
||||
weaponParams={data.weaponParams}
|
||||
specialPoints={data.specialPoints}
|
||||
damageMultipliers={data.damageMultipliers}
|
||||
versions={data.versions}
|
||||
patchHistory={data.patchHistory}
|
||||
kitPatchHistories={data.kitPatchHistories}
|
||||
kits={data.kits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function weaponImageUrl(kind: WeaponParamKind, weaponId: number) {
|
||||
if (kind === "sub") return subWeaponImageUrl(weaponId as SubWeaponId);
|
||||
if (kind === "special") {
|
||||
return specialWeaponImageUrl(weaponId as SpecialWeaponId);
|
||||
}
|
||||
return outlinedMainWeaponImageUrl(weaponId as MainWeaponId);
|
||||
}
|
||||
|
||||
function comparedAcross(kind: WeaponParamKind) {
|
||||
if (kind === "sub") return "all sub weapons";
|
||||
if (kind === "special") return "all special weapons";
|
||||
return "the weapon's category";
|
||||
}
|
||||
48
app/features/params/weapon-params-constants.ts
Normal file
48
app/features/params/weapon-params-constants.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
|
||||
|
||||
/**
|
||||
* Sentinel `category` used for the special points entry in patch change data, since special
|
||||
* points are not a regular weapon parameter. The matching `key` is also this value.
|
||||
*/
|
||||
export const SPECIAL_POINTS_PARAM_KEY = "__specialPoints__";
|
||||
|
||||
/**
|
||||
* Sentinel `category` used for damage multiplier (damage rate vs objects) entries in patch
|
||||
* change data. The `key` of such a change holds the damage receiver target instead.
|
||||
*/
|
||||
export const DAMAGE_MULTIPLIER_PARAM_KEY = "__damageMultiplier__";
|
||||
|
||||
/**
|
||||
* Sentinel `category` used for incoming damage multiplier entries in patch change data: a change
|
||||
* to some *other* weapon's damage rate against the page's sub or special weapon (which is itself a
|
||||
* damageable object). The `key` holds the damage receiver target, and `attackers` holds the
|
||||
* weapons whose rate changed.
|
||||
*/
|
||||
export const INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY =
|
||||
"__incomingDamageMultiplier__";
|
||||
|
||||
/**
|
||||
* Maps a sub or special weapon to the object {@link DAMAGE_RECEIVERS} target(s) that represent it,
|
||||
* so the patch history can surface changes to other weapons' damage rates *against* the kit's sub
|
||||
* or special. Only weapons that exist as a damageable object are listed.
|
||||
*/
|
||||
export const INCOMING_DAMAGE_RECEIVERS: Record<
|
||||
"sub" | "special",
|
||||
Record<number, readonly DamageReceiver[]>
|
||||
> = {
|
||||
special: {
|
||||
2: ["GreatBarrier_Barrier", "GreatBarrier_WeakPoint"], // Big Bubbler
|
||||
6: ["NiceBall_Armor"], // Booyah Bomb
|
||||
7: ["ShockSonar"], // Wave Breaker
|
||||
8: ["BlowerInhale"], // Ink Vac
|
||||
12: ["Chariot"], // Crab Tank
|
||||
16: ["Decoy"], // Super Chump
|
||||
18: ["BulletPogo"], // Triple Splashdown
|
||||
},
|
||||
sub: {
|
||||
3: ["Wsb_Sprinkler"], // Sprinkler
|
||||
4: ["Wsb_Shield"], // Splash Wall
|
||||
8: ["Wsb_Flag"], // Squid Beakon
|
||||
13: ["Bomb_TorpedoBullet"], // Torpedo
|
||||
},
|
||||
};
|
||||
136
app/features/params/weapon-params-types.ts
Normal file
136
app/features/params/weapon-params-types.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { ParamChangeKind } from "./core/param-directions";
|
||||
|
||||
export interface WeaponKitInfo {
|
||||
weaponId: MainWeaponId;
|
||||
subWeaponId: SubWeaponId;
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}
|
||||
|
||||
export interface ParamValueWithHistory {
|
||||
current: number | string;
|
||||
history: Array<{ version: string; value: number | string }>;
|
||||
}
|
||||
|
||||
/** Which set of weapons a params page compares: main weapons, sub weapons or special weapons. */
|
||||
export type WeaponParamKind = "main" | "sub" | "special";
|
||||
|
||||
const WEAPON_PARAM_KIND_KEY_PREFIX: Record<WeaponParamKind, string> = {
|
||||
main: "MAIN",
|
||||
sub: "SUB",
|
||||
special: "SPECIAL",
|
||||
};
|
||||
|
||||
/** The i18next `weapons` namespace key for a weapon of the given {@link WeaponParamKind}. */
|
||||
export function weaponTranslationKey(kind: WeaponParamKind, id: number) {
|
||||
return `weapons:${WEAPON_PARAM_KIND_KEY_PREFIX[kind]}_${id}`;
|
||||
}
|
||||
|
||||
export interface ParsedWeaponParams {
|
||||
weaponId: number;
|
||||
categories: Record<string, Record<string, ParamValueWithHistory>>;
|
||||
}
|
||||
|
||||
export interface ParamDefinition {
|
||||
category: string;
|
||||
key: string;
|
||||
fullKey: string;
|
||||
}
|
||||
|
||||
/** A single weapon's numeric value for one parameter, used by the cross-weapon comparison chart. */
|
||||
export interface ParamComparisonEntry {
|
||||
weaponId: number;
|
||||
value: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpecialPointWithHistory {
|
||||
weaponId: MainWeaponId;
|
||||
current: number;
|
||||
history: Array<{ version: string; value: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* History of a weapon's damage multiplier against a single object (a {@link DAMAGE_RECEIVERS}
|
||||
* target), surfaced only in the patch history. `target` is the receiver key used by the object
|
||||
* damage calculator.
|
||||
*/
|
||||
export interface DamageMultiplierWithHistory {
|
||||
target: string;
|
||||
current: number;
|
||||
history: Array<{ version: string; value: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of weapons whose damage rate against an object changed together. Carried by an incoming
|
||||
* damage multiplier {@link PatchChange} so the badge can show the attacking weapons' icons.
|
||||
*/
|
||||
export interface IncomingDamageAttackers {
|
||||
mainWeaponIds: MainWeaponId[];
|
||||
subWeaponIds: SubWeaponId[];
|
||||
specialWeaponIds: SpecialWeaponId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* History of some attacking weapons' shared damage multiplier against a single object
|
||||
* ({@link DamageMultiplierWithHistory} from the defender's perspective): the page's sub or special
|
||||
* weapon is the object being damaged. `target` is the receiver key used by the object damage
|
||||
* calculator.
|
||||
*/
|
||||
export interface IncomingDamageMultiplierWithHistory {
|
||||
target: string;
|
||||
attackers: IncomingDamageAttackers;
|
||||
current: number;
|
||||
history: Array<{ version: string; value: number }>;
|
||||
}
|
||||
|
||||
export interface PatchChange {
|
||||
category: string;
|
||||
key: string;
|
||||
from: number | string;
|
||||
to: number | string;
|
||||
kind: ParamChangeKind;
|
||||
/** The specific kit a special points change belongs to. Only set for special points. */
|
||||
weaponId?: MainWeaponId;
|
||||
/**
|
||||
* Which weapon of a kit the change belongs to. Used by the per-kit patch history to group a
|
||||
* column's changes under a divider per weapon. Only set for kit patch histories.
|
||||
*/
|
||||
source?: WeaponParamKind;
|
||||
/** The weapons whose rate changed. Only set for incoming damage multiplier changes. */
|
||||
attackers?: IncomingDamageAttackers;
|
||||
}
|
||||
|
||||
export interface WeaponPatch {
|
||||
version: string;
|
||||
date: string | null;
|
||||
changes: PatchChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch history of a single main weapon kit, folding the main weapon's changes together with its
|
||||
* sub and special weapon's changes. Each {@link PatchChange} carries a `source` so the changes can
|
||||
* be grouped per weapon within a patch.
|
||||
*/
|
||||
export interface KitPatchHistory {
|
||||
weaponId: MainWeaponId;
|
||||
subWeaponId: SubWeaponId;
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
patches: WeaponPatch[];
|
||||
}
|
||||
|
||||
export interface WeaponParamsTableProps {
|
||||
kind: WeaponParamKind;
|
||||
currentWeaponId: number;
|
||||
categoryWeaponIds: number[];
|
||||
weaponParams: Record<string, ParsedWeaponParams>;
|
||||
/** Special points are only tracked for main weapons. */
|
||||
specialPoints?: Record<string, SpecialPointWithHistory[]>;
|
||||
/** Damage multipliers (damage rate vs objects), keyed by weapon id. */
|
||||
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
|
||||
versions: string[];
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sub } from "date-fns";
|
||||
import { Ban, Swords } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
@@ -9,20 +8,14 @@ import {
|
||||
} 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 {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { resolveRoomPass } from "~/components/match-page/utils";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import type { loader } from "../loaders/scrims.$id.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
|
||||
export function ScrimMatchBanner() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
const screenLegal = !data.anyUserPrefersNoScreen;
|
||||
|
||||
@@ -45,17 +38,8 @@ export function ScrimMatchBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
const acceptedRequest = data.post.requests[0];
|
||||
const activeRoomLink = resolveActiveRoomLink({
|
||||
roomLinks: data.roomLinks,
|
||||
freshnessCutoff: dateToDatabaseTimestamp(
|
||||
sub(new Date(), { minutes: SCRIM.ROOM_LINK_FRESHNESS_MINUTES }),
|
||||
),
|
||||
viewerUserId: user?.id,
|
||||
members: [...data.post.users, ...acceptedRequest.users],
|
||||
});
|
||||
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
|
||||
const joinPool = Scrim.resolvePoolCode(data.post.id);
|
||||
const joinPass = resolveRoomPass(data.post.id);
|
||||
|
||||
const currentMap = data.mapByMap.currentMap;
|
||||
|
||||
@@ -68,7 +52,7 @@ export function ScrimMatchBanner() {
|
||||
mode={currentMap.mode}
|
||||
screenLegal={screenLegal}
|
||||
joinPool={joinPool}
|
||||
joinViaQr={joinViaQr}
|
||||
joinPass={joinPass}
|
||||
/>
|
||||
</MatchBannerContainer>
|
||||
);
|
||||
@@ -83,7 +67,7 @@ export function ScrimMatchBanner() {
|
||||
subtitle={t("scrims:banner.freeForm.subtitle")}
|
||||
screenLegal={screenLegal}
|
||||
joinPool={joinPool}
|
||||
joinViaQr={joinViaQr}
|
||||
joinPass={joinPass}
|
||||
/>
|
||||
</MatchBannerContainer>
|
||||
);
|
||||
|
||||
@@ -1,59 +1,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 {
|
||||
databaseTimestampToJavascriptTimestamp,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { databaseTimestampToJavascriptTimestamp } 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"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { onConfirmRoom, isConfirming } = useConfirmRoom();
|
||||
|
||||
const acceptedRequest = data.post.requests[0];
|
||||
const allMembers = [...data.post.users, ...acceptedRequest.users];
|
||||
|
||||
const activeRoomLink = resolveActiveRoomLink({
|
||||
roomLinks: data.roomLinks,
|
||||
freshnessCutoff: dateToDatabaseTimestamp(
|
||||
sub(new Date(), { minutes: SCRIM.ROOM_LINK_FRESHNESS_MINUTES }),
|
||||
),
|
||||
viewerUserId: user?.id,
|
||||
members: allMembers,
|
||||
});
|
||||
|
||||
const tabs = resolveTabs(data);
|
||||
|
||||
return (
|
||||
<MatchTabs tabs={tabs}>
|
||||
<MatchJoinTab
|
||||
{...activeRoomLink}
|
||||
onConfirmRoom={onConfirmRoom}
|
||||
isConfirming={isConfirming}
|
||||
pool={Scrim.resolvePoolCode(data.post.id)}
|
||||
pass={resolveRoomPass(data.post.id)}
|
||||
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
|
||||
/>
|
||||
<MatchRosterTab
|
||||
minMembersPerTeam={4}
|
||||
teams={[
|
||||
@@ -96,7 +64,6 @@ export function ScrimMatchTabs() {
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { chatAccessible } from "~/features/chat/chat-utils";
|
||||
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { notFoundIfFalsy } from "../../../utils/remix.server";
|
||||
@@ -31,12 +30,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
const participantIds = Scrim.participantIdsListFromAccepted(post);
|
||||
|
||||
const [anyUserPrefersNoScreen, anyUserPrefersNoSplatnet, roomLinks] =
|
||||
await Promise.all([
|
||||
UserRepository.anyUserPrefersNoScreen(participantIds),
|
||||
UserRepository.anyUserPrefersNoSplatnet(participantIds),
|
||||
RoomLinkRepository.findByUserIds(participantIds, 3),
|
||||
]);
|
||||
const anyUserPrefersNoScreen =
|
||||
await UserRepository.anyUserPrefersNoScreen(participantIds);
|
||||
|
||||
const mapByMap = await resolveMapByMap({ post, user });
|
||||
|
||||
@@ -52,8 +47,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
? post.chatCode
|
||||
: undefined,
|
||||
anyUserPrefersNoScreen,
|
||||
anyUserPrefersNoSplatnet,
|
||||
roomLinks,
|
||||
mapByMap,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ export const SCRIM = {
|
||||
CANCEL_REASON_MAX_LENGTH: 500,
|
||||
REQUEST_MESSAGE_MAX_LENGTH: 200,
|
||||
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
|
||||
ROOM_LINK_FRESHNESS_MINUTES: 30,
|
||||
AUTO_CANCEL_WINDOW_HOURS: 1,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomR
|
||||
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
|
||||
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
|
||||
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
|
||||
import { resolveRoomPass } from "~/components/match-page/utils";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
|
||||
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
@@ -90,16 +90,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
|
||||
);
|
||||
|
||||
const joinPool = isParticipant ? `SQ${String(data.match.id).at(-1)}` : null;
|
||||
const activeRoomLink = resolveActiveRoomLink({
|
||||
roomLinks: data.roomLinks,
|
||||
freshnessCutoff: data.match.createdAt,
|
||||
viewerUserId: user?.id,
|
||||
members: [
|
||||
...data.match.groupAlpha.members,
|
||||
...data.match.groupBravo.members,
|
||||
],
|
||||
});
|
||||
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
|
||||
const joinPass = isParticipant ? resolveRoomPass(data.match.id) : null;
|
||||
|
||||
return (
|
||||
<MatchBannerContainer>
|
||||
@@ -112,7 +103,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
|
||||
teamName: cancelRequesterName,
|
||||
})}
|
||||
joinPool={joinPool}
|
||||
joinViaQr={joinViaQr}
|
||||
joinPass={joinPass}
|
||||
/>
|
||||
) : (
|
||||
<MatchBanner
|
||||
@@ -122,7 +113,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
|
||||
!data.match.groupAlpha.noScreen && !data.match.groupBravo.noScreen
|
||||
}
|
||||
joinPool={joinPool}
|
||||
joinViaQr={joinViaQr}
|
||||
joinPass={joinPass}
|
||||
>
|
||||
<CurrentMapVotesBadge voters={currentMap.voters} />
|
||||
</MatchBanner>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
|
||||
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
|
||||
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
|
||||
import { MatchTabs } from "~/components/match-page/MatchTabs";
|
||||
import { resolveRoomPass } from "~/components/match-page/utils";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import {
|
||||
resolveActiveRoomLink,
|
||||
useConfirmRoom,
|
||||
} from "~/features/chat/room-link-utils";
|
||||
import { ACTION_TAB_AFTER_LOCKED_SECONDS } from "~/features/sendouq/q-constants";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
@@ -28,7 +22,6 @@ import { SendouQMatchActionTab } from "./SendouQMatchActionTab";
|
||||
export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const { onConfirmRoom, isConfirming } = useConfirmRoom();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(["q"]);
|
||||
@@ -90,8 +83,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
|
||||
reporterSide !== null &&
|
||||
reporterSide !== userSide;
|
||||
|
||||
const tabs: Array<"join" | "rosters" | "action" | "result"> = ["rosters"];
|
||||
if (!isLocked && isParticipant) tabs.push("join");
|
||||
const tabs: Array<"rosters" | "action" | "result"> = ["rosters"];
|
||||
if (showActionTab) tabs.push("action");
|
||||
if (isLocked || hasReportedMaps) tabs.push("result");
|
||||
|
||||
@@ -99,18 +91,6 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
|
||||
? ["action"]
|
||||
: undefined;
|
||||
|
||||
const allMembers = [
|
||||
...data.match.groupAlpha.members,
|
||||
...data.match.groupBravo.members,
|
||||
];
|
||||
|
||||
const activeRoomLink = resolveActiveRoomLink({
|
||||
roomLinks: data.roomLinks,
|
||||
freshnessCutoff: data.match.createdAt,
|
||||
viewerUserId: user?.id,
|
||||
members: allMembers,
|
||||
});
|
||||
|
||||
const ownGroup =
|
||||
userSide === "ALPHA"
|
||||
? data.match.groupAlpha
|
||||
@@ -146,16 +126,6 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
|
||||
) : null}
|
||||
</MatchResultTab>
|
||||
) : null}
|
||||
{!isLocked && isParticipant ? (
|
||||
<MatchJoinTab
|
||||
{...activeRoomLink}
|
||||
onConfirmRoom={onConfirmRoom}
|
||||
isConfirming={isConfirming}
|
||||
pool={`SQ${String(data.match.id).at(-1)}`}
|
||||
pass={resolveRoomPass(data.match.id)}
|
||||
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
|
||||
/>
|
||||
) : null}
|
||||
<MatchRosterTab
|
||||
minMembersPerTeam={4}
|
||||
canEditSubbedOut={[false, false]}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { chatAccessible } from "~/features/chat/chat-utils";
|
||||
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
@@ -31,22 +29,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
const isStaff = user?.roles.includes("STAFF") ?? false;
|
||||
const isParticipant = Boolean(user && matchUsers.includes(user.id));
|
||||
const canSeeRoomLinks = isStaff || isParticipant;
|
||||
|
||||
const [privateNotes, roomLinks, anyUserPrefersNoSplatnet, reportedWeapons] =
|
||||
await Promise.all([
|
||||
user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined,
|
||||
canSeeRoomLinks ? RoomLinkRepository.findByUserIds(matchUsers, 3) : [],
|
||||
UserRepository.anyUserPrefersNoSplatnet(matchUsers),
|
||||
ReportedWeaponRepository.findByMatchId(matchId),
|
||||
]);
|
||||
const [privateNotes, reportedWeapons] = await Promise.all([
|
||||
user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined,
|
||||
ReportedWeaponRepository.findByMatchId(matchId),
|
||||
]);
|
||||
|
||||
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
|
||||
|
||||
return {
|
||||
match,
|
||||
roomLinks,
|
||||
anyUserPrefersNoSplatnet,
|
||||
reportedWeapons,
|
||||
isOffSeason: Seasons.current() === null,
|
||||
chatCode: (() => {
|
||||
|
||||
@@ -58,12 +58,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_DEFAULT_MATCH_PAGE_TAB": {
|
||||
await UserRepository.updateOwnPreferences({
|
||||
defaultMatchPageTab: data.newValue,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_MATCH_PROFILE": {
|
||||
await MatchProfileRepository.updateOwnMatchProfile({
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
@@ -71,7 +65,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
languages: data.languages,
|
||||
weaponPool: data.weaponPool,
|
||||
noScreen: Number(data.noScreen),
|
||||
noSplatnet: Number(data.noSplatnet),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export function MatchProfileTab() {
|
||||
vc: matchProfile.vc ?? "NO",
|
||||
languages: matchProfile.languages ?? [],
|
||||
noScreen: Boolean(matchProfile.noScreen),
|
||||
noSplatnet: Boolean(matchProfile.noSplatnet),
|
||||
}}
|
||||
revalidateRoot
|
||||
>
|
||||
@@ -50,7 +49,6 @@ export function MatchProfileTab() {
|
||||
<FormField name="weaponPool" />
|
||||
<FormField name="vc" />
|
||||
<FormField name="languages" />
|
||||
<FormField name="noSplatnet" />
|
||||
<FormField name="noScreen" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function ModeMapPoolPicker({
|
||||
onChange,
|
||||
modeTabs,
|
||||
onModeChange,
|
||||
disabled,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
amountToPick: number;
|
||||
@@ -28,6 +29,8 @@ export function ModeMapPoolPicker({
|
||||
/** When provided, the divider becomes a tab switcher between these modes. */
|
||||
modeTabs?: ModeShort[];
|
||||
onModeChange?: (mode: ModeShort) => void;
|
||||
/** When true, stages can't be picked or removed (view-only). */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [wigglingStageId, setWigglingStageId] = React.useState<StageId | null>(
|
||||
null,
|
||||
@@ -114,6 +117,7 @@ export function ModeMapPoolPicker({
|
||||
const selected = stages.includes(stageId);
|
||||
|
||||
const onClick = () => {
|
||||
if (disabled) return;
|
||||
if (isTiebreaker) return;
|
||||
if (banned) return;
|
||||
if (selected) return handlePickedStageClick(stageId);
|
||||
@@ -130,6 +134,7 @@ export function ModeMapPoolPicker({
|
||||
banned={banned}
|
||||
tiebreaker={isTiebreaker}
|
||||
wiggle={wigglingStageId === stageId}
|
||||
disabled={disabled}
|
||||
testId={`map-pool-${mode}-${stageId}`}
|
||||
/>
|
||||
);
|
||||
@@ -158,6 +163,7 @@ function MapButton({
|
||||
banned,
|
||||
tiebreaker,
|
||||
wiggle,
|
||||
disabled,
|
||||
testId,
|
||||
}: {
|
||||
stageId: StageId;
|
||||
@@ -166,6 +172,7 @@ function MapButton({
|
||||
banned?: boolean;
|
||||
tiebreaker?: boolean;
|
||||
wiggle?: boolean;
|
||||
disabled?: boolean;
|
||||
testId: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
@@ -181,7 +188,7 @@ function MapButton({
|
||||
})}
|
||||
style={{ "--map-image-url": `url("${stageImageUrl(stageId)}.avif")` }}
|
||||
onClick={onClick}
|
||||
disabled={banned}
|
||||
disabled={disabled || banned}
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Config } from "~/config";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import {
|
||||
defaultMatchPageTabSchema,
|
||||
disableBuildAbilitySortingSchema,
|
||||
disallowScrimPickupsFromUntrustedSchema,
|
||||
spoilerFreeModeSchema,
|
||||
@@ -58,18 +57,6 @@ export function PreferencesTab() {
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
<SendouForm
|
||||
schema={defaultMatchPageTabSchema}
|
||||
defaultValues={{
|
||||
newValue: user.preferences.defaultMatchPageTab ?? "rosters",
|
||||
}}
|
||||
autoSubmit
|
||||
revalidateRoot
|
||||
fullWidth
|
||||
hideRequiredIndicator
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,10 +65,6 @@ export const updateMatchProfileSchema = z.object({
|
||||
label: "labels.languages",
|
||||
items: LANGUAGE_OPTIONS,
|
||||
}),
|
||||
noSplatnet: toggle({
|
||||
label: "labels.noSplatnet",
|
||||
bottomText: "bottomTexts.noScreen",
|
||||
}),
|
||||
noScreen: toggle({
|
||||
label: "labels.noScreen",
|
||||
bottomText: "bottomTexts.noScreen",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
customField,
|
||||
radioGroup,
|
||||
select,
|
||||
stringConstant,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { customField, select, stringConstant, toggle } from "~/form/fields";
|
||||
import { themeInputSchema } from "~/utils/zod";
|
||||
|
||||
const customThemeSchema = z.object({
|
||||
@@ -54,18 +48,6 @@ const weaponReportDefaultOpenSchema = z.object({
|
||||
newValue: z.boolean(),
|
||||
});
|
||||
|
||||
export const defaultMatchPageTabSchema = z.object({
|
||||
_action: stringConstant("UPDATE_DEFAULT_MATCH_PAGE_TAB"),
|
||||
newValue: radioGroup({
|
||||
label: "labels.defaultMatchPageTab",
|
||||
items: [
|
||||
{ value: "rosters", label: "options.defaultMatchPageTab.rosters" },
|
||||
{ value: "join", label: "options.defaultMatchPageTab.join" },
|
||||
{ value: "action", label: "options.defaultMatchPageTab.action" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export const settingsEditSchema = z.union([
|
||||
customThemeSchema,
|
||||
disableBuildAbilitySortingSchema,
|
||||
@@ -73,5 +55,4 @@ export const settingsEditSchema = z.union([
|
||||
spoilerFreeModeSchema,
|
||||
clockFormatSchema,
|
||||
weaponReportDefaultOpenSchema,
|
||||
defaultMatchPageTabSchema,
|
||||
]);
|
||||
|
||||
@@ -63,10 +63,8 @@ const TOURNAMENT_SUB_QUOTA = 2;
|
||||
|
||||
export async function resolveSidebarData(userId: number | null) {
|
||||
if (!userId) {
|
||||
const tournamentsData =
|
||||
await ShowcaseTournaments.categorizedTournamentsByUserId(null);
|
||||
return {
|
||||
events: showcaseEventsToSidebarEvents(tournamentsData.showcase),
|
||||
events: [] as SidebarEvent[],
|
||||
friends: [] as SidebarFriend[],
|
||||
streams: await combinedStreamsCached(),
|
||||
savedTournamentIds: [] as number[],
|
||||
@@ -102,16 +100,9 @@ export async function resolveSidebarData(userId: number | null) {
|
||||
|
||||
const scrimEvents: SidebarEvent[] = scrimsData.map(scrimToSidebarEvent);
|
||||
|
||||
const personalEvents = [
|
||||
...tournamentEvents,
|
||||
...savedEvents,
|
||||
...scrimEvents,
|
||||
].sort((a, b) => a.startTime - b.startTime);
|
||||
const events = (
|
||||
personalEvents.length > 0
|
||||
? personalEvents
|
||||
: showcaseEventsToSidebarEvents(tournamentsData.showcase)
|
||||
).slice(0, MAX_EVENTS_VISIBLE);
|
||||
const events = [...tournamentEvents, ...savedEvents, ...scrimEvents]
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
.slice(0, MAX_EVENTS_VISIBLE);
|
||||
|
||||
const friends = resolveFriends(friendsWithActivity);
|
||||
|
||||
@@ -363,19 +354,6 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function showcaseEventsToSidebarEvents(
|
||||
events: ShowcaseCalendarEvent[],
|
||||
): SidebarEvent[] {
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
url: e.url,
|
||||
logoUrl: e.logoUrl,
|
||||
startTime: e.startTime,
|
||||
type: "tournament" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function rowToSidebarFriend(
|
||||
row: FriendWithActivity,
|
||||
subtitle: string,
|
||||
|
||||
@@ -71,13 +71,15 @@ export default function TournamentAdminTeamsPage() {
|
||||
>
|
||||
Export
|
||||
</SendouButton>
|
||||
<LinkButton
|
||||
size="small"
|
||||
icon={<Plus />}
|
||||
to={tournamentAdminRegistrationPage(tournament.ctx.id)}
|
||||
>
|
||||
Add new team
|
||||
</LinkButton>
|
||||
{!tournament.ctx.isFinalized ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
icon={<Plus />}
|
||||
to={tournamentAdminRegistrationPage(tournament.ctx.id)}
|
||||
>
|
||||
Add new team
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
@@ -98,7 +100,7 @@ export default function TournamentAdminTeamsPage() {
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
/>
|
||||
<th>Actions</th>
|
||||
{!tournament.ctx.isFinalized ? <th>Actions</th> : null}
|
||||
<SortableTableHeader
|
||||
label="Check-in"
|
||||
sortKey="checkIn"
|
||||
@@ -124,7 +126,10 @@ export default function TournamentAdminTeamsPage() {
|
||||
))}
|
||||
{sortedTeams.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={maxRosterSize + 3} className={styles.noResults}>
|
||||
<td
|
||||
colSpan={maxRosterSize + (tournament.ctx.isFinalized ? 2 : 3)}
|
||||
className={styles.noResults}
|
||||
>
|
||||
No registrations yet
|
||||
</td>
|
||||
</tr>
|
||||
@@ -175,9 +180,11 @@ function TeamRow({
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<TeamRowMenu team={team} editPage={editPage} />
|
||||
</td>
|
||||
{!tournament.ctx.isFinalized ? (
|
||||
<td>
|
||||
<TeamRowMenu team={team} editPage={editPage} />
|
||||
</td>
|
||||
) : null}
|
||||
<td>
|
||||
<CheckInCell team={team} />
|
||||
</td>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useFetcher } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { tournamentAdminPage } from "~/utils/urls";
|
||||
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.brackets.server";
|
||||
@@ -26,6 +28,10 @@ export default function TournamentAdminBracketsPage() {
|
||||
tournament.hasStarted &&
|
||||
!tournament.ctx.isFinalized;
|
||||
|
||||
if (tournament.ctx.isFinalized && !showReopen) {
|
||||
return <Redirect to={tournamentAdminPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{showEditBrackets ? (
|
||||
@@ -119,8 +125,9 @@ function BracketReset() {
|
||||
function BracketProgressionEdit() {
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const [bracketProgressionErrored, setBracketProgressionErrored] =
|
||||
React.useState(false);
|
||||
const [bracketProgression, setBracketProgression] = React.useState<
|
||||
Progression.ParsedBracket[] | null
|
||||
>(tournament.ctx.settings.bracketProgression);
|
||||
|
||||
const disabledBracketIdxs = tournament.brackets
|
||||
.filter((bracket) => !bracket.preview)
|
||||
@@ -128,6 +135,13 @@ function BracketProgressionEdit() {
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post">
|
||||
{bracketProgression ? (
|
||||
<input
|
||||
type="hidden"
|
||||
name="bracketProgression"
|
||||
value={JSON.stringify(bracketProgression)}
|
||||
/>
|
||||
) : null}
|
||||
<BracketProgressionSelector
|
||||
initialBrackets={Progression.validatedBracketsToInputFormat(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
@@ -136,13 +150,13 @@ function BracketProgressionEdit() {
|
||||
disabled: disabledBracketIdxs.includes(idx),
|
||||
}))}
|
||||
isInvitationalTournament={tournament.isInvitational}
|
||||
setErrored={setBracketProgressionErrored}
|
||||
onChange={setBracketProgression}
|
||||
isTournamentInProgress
|
||||
/>
|
||||
<div className="stack md horizontal justify-center mt-6">
|
||||
<SubmitButton
|
||||
_action="UPDATE_TOURNAMENT_PROGRESSION"
|
||||
isDisabled={bracketProgressionErrored}
|
||||
isDisabled={!bracketProgression}
|
||||
>
|
||||
Save changes
|
||||
</SubmitButton>
|
||||
|
||||
@@ -72,7 +72,9 @@ export default function TournamentAdminRegistrationPage() {
|
||||
ownerId: owner ? String(owner.userId) : "",
|
||||
members: team.members.map((member) => ({
|
||||
userId: member.userId,
|
||||
inGameName: member.inGameName ?? null,
|
||||
inGameName: tournament.ctx.settings.requireInGameNames
|
||||
? (member.inGameName ?? null)
|
||||
: null,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { tournamentAdminPage } from "~/utils/urls";
|
||||
import { adminStreamFormSchema } from "../tournament-admin-staff-schemas";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.stream.server";
|
||||
@@ -7,6 +9,10 @@ export { action } from "../actions/to.$id.admin.stream.server";
|
||||
export default function TournamentAdminStreamPage() {
|
||||
const tournament = useTournament();
|
||||
|
||||
if (tournament.ctx.isFinalized) {
|
||||
return <Redirect to={tournamentAdminPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SendouForm
|
||||
schema={adminStreamFormSchema}
|
||||
|
||||
@@ -54,14 +54,13 @@ export default function TournamentAdminLayout() {
|
||||
tournament.hasStarted &&
|
||||
!tournament.ctx.isFinalized;
|
||||
const showStaffTab = tournament.isAdmin(user);
|
||||
const showBracketsTab =
|
||||
!tournament.isLeagueSignup || showEditBrackets || showReopen;
|
||||
const showBracketsTab = tournament.ctx.isFinalized
|
||||
? showReopen
|
||||
: !tournament.isLeagueSignup || showEditBrackets;
|
||||
const showStreamTab = !tournament.ctx.isFinalized;
|
||||
const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup;
|
||||
|
||||
if (
|
||||
!tournament.isOrganizer(user) ||
|
||||
(tournament.ctx.isFinalized && !DANGEROUS_CAN_ACCESS_DEV_CONTROLS)
|
||||
) {
|
||||
if (!tournament.isOrganizer(user)) {
|
||||
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
@@ -132,9 +131,11 @@ export default function TournamentAdminLayout() {
|
||||
{t("tournament:admin.tab.staff")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
<SendouTab id="stream" href={`${adminPage}/stream`} icon={<Tv />}>
|
||||
{t("tournament:admin.tab.stream")}
|
||||
</SendouTab>
|
||||
{showStreamTab ? (
|
||||
<SendouTab id="stream" href={`${adminPage}/stream`} icon={<Tv />}>
|
||||
{t("tournament:admin.tab.stream")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{showBracketsTab ? (
|
||||
<SendouTab
|
||||
id="brackets"
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import { tournamentTeamNameTaken } from "~/features/tournament/tournament-utils.server";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { adminRegistrationFormSchema } from "./tournament-admin-registration-schemas";
|
||||
import {
|
||||
ADMIN_REGISTRATION_MAX_MEMBERS,
|
||||
adminRegistrationFormSchema,
|
||||
} from "./tournament-admin-registration-schemas";
|
||||
|
||||
/**
|
||||
* Extends the client {@link adminRegistrationFormSchema} with server-only,
|
||||
@@ -38,7 +41,7 @@ export function adminRegistrationFormSchemaServer({
|
||||
});
|
||||
}
|
||||
|
||||
if (data.members.length > tournament.maxMembersPerTeam) {
|
||||
if (data.members.length > ADMIN_REGISTRATION_MAX_MEMBERS) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.regTooManyMembers",
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
tournamentSearchOptional,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { TEAM } from "../team/team-constants";
|
||||
/**
|
||||
* Roster size cap for organizer-managed registrations. The per-tournament
|
||||
* `maxMembersPerTeam` limit intentionally doesn't apply to organizers, so this
|
||||
* is just a generous safety ceiling rather than a competitive constraint.
|
||||
*/
|
||||
export const ADMIN_REGISTRATION_MAX_MEMBERS = 20;
|
||||
|
||||
const memberFieldset = fieldset({
|
||||
fields: z.object({
|
||||
@@ -44,7 +49,7 @@ export const adminRegistrationFormSchema = z
|
||||
members: array({
|
||||
label: "labels.members",
|
||||
min: 1,
|
||||
max: TEAM.MAX_MEMBER_COUNT,
|
||||
max: ADMIN_REGISTRATION_MAX_MEMBERS,
|
||||
field: memberFieldset,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -29,6 +29,59 @@ describe("swiss standings - losses against tied", () => {
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(1);
|
||||
});
|
||||
|
||||
it("breaks ties on losses against tied, not wins against tied", () => {
|
||||
const tournament = new Tournament({
|
||||
...LOW_INK_DECEMBER_2024(),
|
||||
simulateBrackets: false,
|
||||
});
|
||||
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(false);
|
||||
|
||||
// Both teams finished 4-2 in the same Swiss group. Team 16872 beat MORE of
|
||||
// its tied peers (winsAgainstTied=2) than team 17505 (winsAgainstTied=1),
|
||||
// but Swiss intentionally ranks on losses against tied (not wins), because
|
||||
// not every tied team has played each other. Both lost to zero tied peers,
|
||||
// so the tiebreaker is a draw and the higher opponent set win % wins out —
|
||||
// placing 17505 above 16872 despite 16872's extra win against a tied team.
|
||||
const moreWinsVsTied = standings.find((s) => s.team.id === 16872);
|
||||
const higherOpponentWinPct = standings.find((s) => s.team.id === 17505);
|
||||
invariant(moreWinsVsTied && higherOpponentWinPct, "Standings not found");
|
||||
|
||||
expect(moreWinsVsTied.stats?.winsAgainstTied).toBe(2);
|
||||
expect(higherOpponentWinPct.stats?.winsAgainstTied).toBe(1);
|
||||
expect(moreWinsVsTied.stats?.lossesAgainstTied).toBe(0);
|
||||
expect(higherOpponentWinPct.stats?.lossesAgainstTied).toBe(0);
|
||||
|
||||
expect(higherOpponentWinPct.placement).toBeLessThan(
|
||||
moreWinsVsTied.placement,
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks fewer losses against tied above a higher opponent set win %", () => {
|
||||
const tournament = new Tournament({
|
||||
...LOW_INK_DECEMBER_2024(),
|
||||
simulateBrackets: false,
|
||||
});
|
||||
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(false);
|
||||
|
||||
// Both teams finished 4-2 in the same Swiss group. Team 16996 lost to none
|
||||
// of its tied peers while team 17067 lost to one, even though 17067 has the
|
||||
// higher opponent set win %. The losses-against-tied tiebreaker is applied
|
||||
// before opponent win %, so 16996 is placed higher.
|
||||
const noTiedLosses = standings.find((s) => s.team.id === 16996);
|
||||
const oneTiedLoss = standings.find((s) => s.team.id === 17067);
|
||||
invariant(noTiedLosses && oneTiedLoss, "Standings not found");
|
||||
|
||||
expect(noTiedLosses.stats?.lossesAgainstTied).toBe(0);
|
||||
expect(oneTiedLoss.stats?.lossesAgainstTied).toBe(1);
|
||||
expect(oneTiedLoss.stats?.opponentSetWinPercentage).toBeGreaterThan(
|
||||
noTiedLosses.stats!.opponentSetWinPercentage!,
|
||||
);
|
||||
|
||||
expect(noTiedLosses.placement).toBeLessThan(oneTiedLoss.placement);
|
||||
});
|
||||
|
||||
it("should ignore early dropped out teams for standings (losses against tied)", () => {
|
||||
const tournament = new Tournament({
|
||||
...LOW_INK_DECEMBER_2024(),
|
||||
|
||||
@@ -368,7 +368,12 @@ export class SwissBracket extends Bracket {
|
||||
if (a.setLosses < b.setLosses) return -1;
|
||||
if (a.setLosses > b.setLosses) return 1;
|
||||
|
||||
// TIEBREAKER 2) wins against tied - ensure that a team who beat more teams that are tied with them is placed higher
|
||||
// TIEBREAKER 2) losses against tied - a team that lost to fewer of the
|
||||
// teams it is tied with is placed higher. Unlike round robin (which uses
|
||||
// wins against tied), Swiss counts losses because not every tied team has
|
||||
// played each other: rewarding wins would unfairly favor teams who simply
|
||||
// faced more of their tied peers, whereas penalizing head-to-head losses is
|
||||
// schedule-independent. (winsAgainstTied is still tracked for display only.)
|
||||
if (a.lossesAgainstTied > b.lossesAgainstTied) return 1;
|
||||
if (a.lossesAgainstTied < b.lossesAgainstTied) return -1;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user