Resolve conflicts and merge main into current branch

This commit is contained in:
lderuijter
2026-06-19 14:56:10 +02:00
284 changed files with 43796 additions and 4265 deletions

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
.input {
position: absolute;
width: 0;
height: 0;
border: none;
opacity: 0;
pointer-events: none;
}

View File

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

View File

@@ -17,7 +17,7 @@
font-size: var(--font-2xs);
}
& tbody tr:hover {
&:not(.noRowHover) tbody tr:hover {
background-color: var(--color-bg-high);
}

View File

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

View File

@@ -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: () => {},

View File

@@ -146,7 +146,7 @@
}
.listBox {
max-height: 300px;
max-height: 325px;
overflow-y: auto;
padding: var(--s-2);
outline: none;

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -895,6 +895,10 @@
"displayName": "Weapon Lockdown",
"authorDiscordId": "338806780446638082"
},
"okitty": {
"displayName": "Orange Kitty",
"authorDiscordId": "752582395076673577"
},
"oktofest-bronze": {
"displayName": "Oktofest LAN (Third)",
"authorDiscordId": "751912670403362836"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import {
calendarNewBaseSchema,
calendarNewSyncRefine,
} from "./calendar-new-schemas";
export const calendarNewSchemaServer = calendarNewBaseSchema.superRefine(
calendarNewSyncRefine,
);

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View 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"]);
});
});

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

View 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";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}
]
}
]
}
}
}

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

View 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";
}

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

View 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[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: (() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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