Weapon params page (#3170)

This commit is contained in:
Kalle 2026-06-17 14:37:44 +03:00 committed by GitHub
parent 0460488dc3
commit abed7fa8bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
96 changed files with 38471 additions and 369 deletions

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

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

@ -1,9 +1,10 @@
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 { CombineWith, DamageReceiver } from "./calculator-types";
import type objectDamages from "./core/object-dmg.json";
export const DAMAGE_RECEIVERS = [
@ -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

@ -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,262 @@
.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;
}
.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;
justify-content: space-between;
gap: var(--s-2);
}
.historyVersion {
color: var(--color-text-high);
}
.historyValue {
font-variant-numeric: tabular-nums;
color: var(--color-accent);
font-weight: var(--weight-semi);
}
.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,679 @@
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;
}) {
const { t } = useTranslation(["analyzer"]);
const suffix = t("analyzer:suffix.specialPointsShort");
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;
return (
<td className={styles.paramCell}>
<div className={styles.cellContent}>
<span className={styles.currentValue}>
{kits.map((kit) => `${kit.current}${suffix}`).join(" / ")}
</span>
{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}>
<WeaponImage
weaponSplId={kit.weaponId}
variant="badge"
size={24}
/>
<div className={styles.specialPointHistoryKitList}>
{kit.history.toReversed().map(({ version, value }) => (
<div key={version} className={styles.historyItem}>
<span className={styles.historyValue}>
{`${value}${suffix}`}
</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 fromto 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

@ -13,6 +13,7 @@ import gameMisc from "../../../locales/en/game-misc.json";
import gear from "../../../locales/en/gear.json";
import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import params from "../../../locales/en/params.json";
import q from "../../../locales/en/q.json";
import scrims from "../../../locales/en/scrims.json";
import settings from "../../../locales/en/settings.json";
@ -40,6 +41,7 @@ export const resources = {
gear,
lfg,
org,
params,
q,
scrims,
settings,

View File

@ -14,6 +14,7 @@ import gameMiscDa from "../../../locales/da/game-misc.json";
import gearDa from "../../../locales/da/gear.json";
import lfgDa from "../../../locales/da/lfg.json";
import orgDa from "../../../locales/da/org.json";
import paramsDa from "../../../locales/da/params.json";
import qDa from "../../../locales/da/q.json";
import scrimsDa from "../../../locales/da/scrims.json";
import settingsDa from "../../../locales/da/settings.json";
@ -39,6 +40,7 @@ import gameMiscDe from "../../../locales/de/game-misc.json";
import gearDe from "../../../locales/de/gear.json";
import lfgDe from "../../../locales/de/lfg.json";
import orgDe from "../../../locales/de/org.json";
import paramsDe from "../../../locales/de/params.json";
import qDe from "../../../locales/de/q.json";
import scrimsDe from "../../../locales/de/scrims.json";
import settingsDe from "../../../locales/de/settings.json";
@ -64,6 +66,7 @@ import gameMisc from "../../../locales/en/game-misc.json";
import gear from "../../../locales/en/gear.json";
import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import params from "../../../locales/en/params.json";
import q from "../../../locales/en/q.json";
import scrimsEn from "../../../locales/en/scrims.json";
import settings from "../../../locales/en/settings.json";
@ -89,6 +92,7 @@ import gameMiscEsEs from "../../../locales/es-ES/game-misc.json";
import gearEsEs from "../../../locales/es-ES/gear.json";
import lfgEsEs from "../../../locales/es-ES/lfg.json";
import orgEsEs from "../../../locales/es-ES/org.json";
import paramsEsEs from "../../../locales/es-ES/params.json";
import qEsEs from "../../../locales/es-ES/q.json";
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
import settingsEsEs from "../../../locales/es-ES/settings.json";
@ -114,6 +118,7 @@ import gameMiscEsUs from "../../../locales/es-US/game-misc.json";
import gearEsUs from "../../../locales/es-US/gear.json";
import lfgEsUs from "../../../locales/es-US/lfg.json";
import orgEsUs from "../../../locales/es-US/org.json";
import paramsEsUs from "../../../locales/es-US/params.json";
import qEsUs from "../../../locales/es-US/q.json";
import scrimsEsUs from "../../../locales/es-US/scrims.json";
import settingsEsUs from "../../../locales/es-US/settings.json";
@ -139,6 +144,7 @@ import gameMiscFrCa from "../../../locales/fr-CA/game-misc.json";
import gearFrCa from "../../../locales/fr-CA/gear.json";
import lfgFrCa from "../../../locales/fr-CA/lfg.json";
import orgFrCa from "../../../locales/fr-CA/org.json";
import paramsFrCa from "../../../locales/fr-CA/params.json";
import qFrCa from "../../../locales/fr-CA/q.json";
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
import settingsFrCa from "../../../locales/fr-CA/settings.json";
@ -164,6 +170,7 @@ import gameMiscFrEu from "../../../locales/fr-EU/game-misc.json";
import gearFrEu from "../../../locales/fr-EU/gear.json";
import lfgFrEu from "../../../locales/fr-EU/lfg.json";
import orgFrEu from "../../../locales/fr-EU/org.json";
import paramsFrEu from "../../../locales/fr-EU/params.json";
import qFrEu from "../../../locales/fr-EU/q.json";
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
import settingsFrEu from "../../../locales/fr-EU/settings.json";
@ -189,6 +196,7 @@ import gameMiscHe from "../../../locales/he/game-misc.json";
import gearHe from "../../../locales/he/gear.json";
import lfgHe from "../../../locales/he/lfg.json";
import orgHe from "../../../locales/he/org.json";
import paramsHe from "../../../locales/he/params.json";
import qHe from "../../../locales/he/q.json";
import scrimsHe from "../../../locales/he/scrims.json";
import settingsHe from "../../../locales/he/settings.json";
@ -214,6 +222,7 @@ import gameMiscIt from "../../../locales/it/game-misc.json";
import gearIt from "../../../locales/it/gear.json";
import lfgIt from "../../../locales/it/lfg.json";
import orgIt from "../../../locales/it/org.json";
import paramsIt from "../../../locales/it/params.json";
import qIt from "../../../locales/it/q.json";
import scrimsIt from "../../../locales/it/scrims.json";
import settingsIt from "../../../locales/it/settings.json";
@ -239,6 +248,7 @@ import gameMiscJa from "../../../locales/ja/game-misc.json";
import gearJa from "../../../locales/ja/gear.json";
import lfgJa from "../../../locales/ja/lfg.json";
import orgJa from "../../../locales/ja/org.json";
import paramsJa from "../../../locales/ja/params.json";
import qJa from "../../../locales/ja/q.json";
import scrimsJa from "../../../locales/ja/scrims.json";
import settingsJa from "../../../locales/ja/settings.json";
@ -264,6 +274,7 @@ import gameMiscKo from "../../../locales/ko/game-misc.json";
import gearKo from "../../../locales/ko/gear.json";
import lfgKo from "../../../locales/ko/lfg.json";
import orgKo from "../../../locales/ko/org.json";
import paramsKo from "../../../locales/ko/params.json";
import qKo from "../../../locales/ko/q.json";
import scrimsKo from "../../../locales/ko/scrims.json";
import settingsKo from "../../../locales/ko/settings.json";
@ -289,6 +300,7 @@ import gameMiscNl from "../../../locales/nl/game-misc.json";
import gearNl from "../../../locales/nl/gear.json";
import lfgNl from "../../../locales/nl/lfg.json";
import orgNl from "../../../locales/nl/org.json";
import paramsNl from "../../../locales/nl/params.json";
import qNl from "../../../locales/nl/q.json";
import scrimsNl from "../../../locales/nl/scrims.json";
import settingsNl from "../../../locales/nl/settings.json";
@ -314,6 +326,7 @@ import gameMiscPl from "../../../locales/pl/game-misc.json";
import gearPl from "../../../locales/pl/gear.json";
import lfgPl from "../../../locales/pl/lfg.json";
import orgPl from "../../../locales/pl/org.json";
import paramsPl from "../../../locales/pl/params.json";
import qPl from "../../../locales/pl/q.json";
import scrimsPl from "../../../locales/pl/scrims.json";
import settingsPl from "../../../locales/pl/settings.json";
@ -339,6 +352,7 @@ import gameMiscPtBr from "../../../locales/pt-BR/game-misc.json";
import gearPtBr from "../../../locales/pt-BR/gear.json";
import lfgPtBr from "../../../locales/pt-BR/lfg.json";
import orgPtBr from "../../../locales/pt-BR/org.json";
import paramsPtBr from "../../../locales/pt-BR/params.json";
import qPtBr from "../../../locales/pt-BR/q.json";
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
import settingsPtBr from "../../../locales/pt-BR/settings.json";
@ -364,6 +378,7 @@ import gameMiscRu from "../../../locales/ru/game-misc.json";
import gearRu from "../../../locales/ru/gear.json";
import lfgRu from "../../../locales/ru/lfg.json";
import orgRu from "../../../locales/ru/org.json";
import paramsRu from "../../../locales/ru/params.json";
import qRu from "../../../locales/ru/q.json";
import scrimsRu from "../../../locales/ru/scrims.json";
import settingsRu from "../../../locales/ru/settings.json";
@ -389,6 +404,7 @@ import gameMiscZh from "../../../locales/zh/game-misc.json";
import gearZh from "../../../locales/zh/gear.json";
import lfgZh from "../../../locales/zh/lfg.json";
import orgZh from "../../../locales/zh/org.json";
import paramsZh from "../../../locales/zh/params.json";
import qZh from "../../../locales/zh/q.json";
import scrimsZh from "../../../locales/zh/scrims.json";
import settingsZh from "../../../locales/zh/settings.json";
@ -421,6 +437,7 @@ export const resources = {
vods: vodsEsUs,
calendar: calendarEsUs,
org: orgEsUs,
params: paramsEsUs,
badges: badgesEsUs,
contributions: contributionsEsUs,
team: teamEsUs,
@ -448,6 +465,7 @@ export const resources = {
vods: vods,
calendar: calendar,
org: org,
params: params,
badges: badges,
contributions: contributions,
team: team,
@ -475,6 +493,7 @@ export const resources = {
vods: vodsKo,
calendar: calendarKo,
org: orgKo,
params: paramsKo,
badges: badgesKo,
contributions: contributionsKo,
team: teamKo,
@ -502,6 +521,7 @@ export const resources = {
vods: vodsDe,
calendar: calendarDe,
org: orgDe,
params: paramsDe,
badges: badgesDe,
contributions: contributionsDe,
team: teamDe,
@ -529,6 +549,7 @@ export const resources = {
vods: vodsNl,
calendar: calendarNl,
org: orgNl,
params: paramsNl,
badges: badgesNl,
contributions: contributionsNl,
team: teamNl,
@ -556,6 +577,7 @@ export const resources = {
vods: vodsPtBr,
calendar: calendarPtBr,
org: orgPtBr,
params: paramsPtBr,
badges: badgesPtBr,
contributions: contributionsPtBr,
team: teamPtBr,
@ -583,6 +605,7 @@ export const resources = {
vods: vodsZh,
calendar: calendarZh,
org: orgZh,
params: paramsZh,
badges: badgesZh,
contributions: contributionsZh,
team: teamZh,
@ -610,6 +633,7 @@ export const resources = {
vods: vodsFrCa,
calendar: calendarFrCa,
org: orgFrCa,
params: paramsFrCa,
badges: badgesFrCa,
contributions: contributionsFrCa,
team: teamFrCa,
@ -637,6 +661,7 @@ export const resources = {
vods: vodsRu,
calendar: calendarRu,
org: orgRu,
params: paramsRu,
badges: badgesRu,
contributions: contributionsRu,
team: teamRu,
@ -664,6 +689,7 @@ export const resources = {
vods: vodsIt,
calendar: calendarIt,
org: orgIt,
params: paramsIt,
badges: badgesIt,
contributions: contributionsIt,
team: teamIt,
@ -691,6 +717,7 @@ export const resources = {
vods: vodsJa,
calendar: calendarJa,
org: orgJa,
params: paramsJa,
badges: badgesJa,
contributions: contributionsJa,
team: teamJa,
@ -718,6 +745,7 @@ export const resources = {
vods: vodsDa,
calendar: calendarDa,
org: orgDa,
params: paramsDa,
badges: badgesDa,
contributions: contributionsDa,
team: teamDa,
@ -745,6 +773,7 @@ export const resources = {
vods: vodsEsEs,
calendar: calendarEsEs,
org: orgEsEs,
params: paramsEsEs,
badges: badgesEsEs,
contributions: contributionsEsEs,
team: teamEsEs,
@ -772,6 +801,7 @@ export const resources = {
vods: vodsHe,
calendar: calendarHe,
org: orgHe,
params: paramsHe,
badges: badgesHe,
contributions: contributionsHe,
team: teamHe,
@ -799,6 +829,7 @@ export const resources = {
vods: vodsFrEu,
calendar: calendarFrEu,
org: orgFrEu,
params: paramsFrEu,
badges: badgesFrEu,
contributions: contributionsFrEu,
team: teamFrEu,
@ -826,6 +857,7 @@ export const resources = {
vods: vodsPl,
calendar: calendarPl,
org: orgPl,
params: paramsPl,
badges: badgesPl,
contributions: contributionsPl,
team: teamPl,

View File

@ -242,6 +242,8 @@ export default [
route("/weapon-usage", "features/sendouq/routes/weapon-usage.ts"),
route("/params/:slug", "features/params/routes/params.$slug.tsx"),
route("/tiers", "features/sendouq/routes/tiers.tsx"),
route(

View File

@ -30,6 +30,7 @@ const ALL_NAMESPACES = [
"front",
"friends",
"settings",
"params",
] as const;
assertType<Namespace, (typeof ALL_NAMESPACES)[number]>();
assertType<(typeof ALL_NAMESPACES)[number], Namespace>();

View File

@ -1,18 +1,40 @@
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import weaponTranslations from "../../locales/en/weapons.json";
import { mySlugify } from "./urls";
const SLUG_TO_WEAPON_ID = Object.fromEntries(
Object.entries(weaponTranslations)
.filter(([id]) => id.startsWith("MAIN"))
.map(([id, name]) => [
mySlugify(name),
Number(id.replace("MAIN_", "")) as MainWeaponId,
]),
) as Record<string, MainWeaponId>;
function buildSlugToIdMap<T extends number>(prefix: string): Record<string, T> {
return Object.fromEntries(
Object.entries(weaponTranslations)
.filter(([id]) => id.startsWith(`${prefix}_`))
.map(([id, name]) => [
mySlugify(name),
Number(id.replace(`${prefix}_`, "")) as T,
]),
) as Record<string, T>;
}
const SLUG_TO_WEAPON_ID = buildSlugToIdMap<MainWeaponId>("MAIN");
const SLUG_TO_SUB_WEAPON_ID = buildSlugToIdMap<SubWeaponId>("SUB");
const SLUG_TO_SPECIAL_WEAPON_ID = buildSlugToIdMap<SpecialWeaponId>("SPECIAL");
export function weaponNameSlugToId(slug?: string) {
if (!slug) return null;
return SLUG_TO_WEAPON_ID[slug.toLowerCase()] ?? null;
}
export function subWeaponNameSlugToId(slug?: string) {
if (!slug) return null;
return SLUG_TO_SUB_WEAPON_ID[slug.toLowerCase()] ?? null;
}
export function specialWeaponNameSlugToId(slug?: string) {
if (!slug) return null;
return SLUG_TO_SPECIAL_WEAPON_ID[slug.toLowerCase()] ?? null;
}

View File

@ -279,6 +279,7 @@ export const weaponBuildStatsPage = (weaponSlug: string) =>
`${weaponBuildPage(weaponSlug)}/stats`;
export const weaponBuildPopularPage = (weaponSlug: string) =>
`${weaponBuildPage(weaponSlug)}/popular`;
export const weaponParamsPage = (weaponSlug: string) => `/params/${weaponSlug}`;
export const calendarPage = (args?: {
filters?: CalendarFilters;

View File

@ -40,10 +40,7 @@ Note: it only works with Node 16.
## Doing monthly update
1. Fill /scripts/dicts with new data from leanny repository:
- weapon = contents of `weapon` folder
- langs = contents of `language` folder
- Couple of others at the root: `GearInfoClothes.json`, `GearInfoHead.json`, `GearInfoShoes.json`, `spl__DamageRateInfoConfig.pp__CombinationDataTableData.json`, `SplPlayer.game__GameParameterTable.json`, `WeaponInfoMain.json`, `WeaponInfoSpecial.json` and `WeaponInfoSub.json`
1. Drop the whole [splat3 repository](https://github.com/Leanny/splat3) into `/scripts/dicts/splat3` (pull before).
1. Update all `CURRENT_SEASON` constants
1. Update `CURRENT_PATCH` constants
1. Update `PATCHES` constant with the late patch + remove the oldest
@ -52,7 +49,8 @@ Note: it only works with Node 16.
1. `pnpm exec vite-node scripts/create-gear-json.ts`
1. `pnpm exec vite-node scripts/create-analyzer-json.ts`
8a. Double check that no hard-coded special damages changed
1. `pnpm exec vite-node scripts/create-object-dmg-json.ts`
1. `pnpm exec vite-node scripts/create-object-dmg-json.ts` (also writes `damage-rate-history.json` for the params page directly into `app/features/params/data`)
1. `pnpm exec vite-node scripts/sync-weapon-params.ts` (writes the weapon/sub/special param histories for the params page, see [Sync weapon params](#sync-weapon-params))
1. Fill new weapon IDs by category to `weapon-ids.ts` (easy to take from the diff of English weapons.json)
1. Get gear IDs for each slot from /output folder and update `gear-ids.ts`.
1. Replace `object-dmg.json` with the `object-dmg.json` in /output folder

119
e2e/params.spec.ts Normal file
View File

@ -0,0 +1,119 @@
import { ANALYZER_URL } from "~/utils/urls";
import {
expect,
impersonate,
navigate,
selectWeapon,
test,
} from "./helpers/playwright";
test.describe("Weapon parameters", () => {
test("table filtering, comparison bar graph and history rows (via Analyzer)", async ({
page,
}) => {
await navigate({ page, url: ANALYZER_URL });
await selectWeapon({ page, name: "Splattershot" });
await page.getByRole("link", { name: /Raw parameters/ }).click();
await expect(page).toHaveURL(/\/params\/splattershot/);
// Filtering: hide a weapon column. Wait for a sibling column to render after the client-side
// navigation before counting, otherwise the count can be taken mid-hydration.
const weaponHeaders = page.locator("th[class*='weaponHeader']");
await expect(weaponHeaders.nth(1)).toBeVisible();
const initialColumnCount = await weaponHeaders.count();
expect(initialColumnCount).toBeGreaterThan(1);
await page.locator("[data-testid^='hide-weapon-']").first().click();
const showAllButton = page.getByTestId("show-all-weapons");
await expect(showAllButton).toBeVisible();
await expect(weaponHeaders).toHaveCount(initialColumnCount - 1);
expect(page.url()).toMatch(/hidden=\d/);
// Refresh keeps the hidden selection
await page.reload();
expect(page.url()).toMatch(/hidden=\d/);
await expect(showAllButton).toBeVisible();
await expect(weaponHeaders).toHaveCount(initialColumnCount - 1);
// Restore all weapons
await showAllButton.click();
await expect(showAllButton).not.toBeVisible();
await expect(weaponHeaders).toHaveCount(initialColumnCount);
expect(page.url()).not.toMatch(/hidden=\d/);
// Comparison bar graph
await page.getByTestId("compare-param").first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
const bars = dialog.locator("[class*='bars'] [class*='row']");
expect(await bars.count()).toBeGreaterThanOrEqual(2);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
// Collapsing a history row after expanding it. The patch-count badge is shown while the row
// is collapsed and hidden once expanded, so it is a reliable signal for the toggle state.
const expandableRow = page.locator("[class*='expandableRow']").first();
await expect(expandableRow).toBeVisible();
const historyBadge = expandableRow
.locator("[class*='historyBadge']")
.first();
await expect(historyBadge).toBeVisible();
await expandableRow.locator("td[class*='paramName']").click();
await expect(historyBadge).not.toBeVisible();
await expandableRow.locator("td[class*='paramName']").click();
await expect(historyBadge).toBeVisible();
});
test("patch history tab persists across refresh (via weapon search)", async ({
page,
}) => {
await impersonate(page);
await navigate({ page, url: "/" });
const searchDialog = page.getByRole("dialog", { name: "Search" });
await page.getByRole("button", { name: /Search/ }).click();
await searchDialog.waitFor({ state: "visible" });
await page.getByPlaceholder("Search...").fill("splattershot");
const weaponOption = page.getByRole("option", {
name: "Splattershot",
exact: true,
});
await weaponOption.waitFor({ state: "visible" });
await weaponOption.click({ force: true });
await page.getByRole("option", { name: "Parameters", exact: true }).click();
await expect(page).toHaveURL(/\/params\/splattershot/);
// Switch to the patch history tab
const patchesTab = page.getByRole("tab", { name: /Patch history/ });
await patchesTab.click();
expect(page.url()).toContain("tab=patches");
// Refresh keeps the selected tab
await page.reload();
expect(page.url()).toContain("tab=patches");
await expect(patchesTab).toHaveAttribute("aria-selected", "true");
// Either patch columns are shown or the empty state
const patchColumns = page.locator("[class*='column']");
await expect(patchColumns.first()).toBeVisible();
// Toggle "Show sub & special changes" and verify it persists on refresh
const subSpecialSwitch = page.getByRole("switch", {
name: /Show sub & special changes/,
});
await expect(subSpecialSwitch).toBeChecked();
await subSpecialSwitch.click({ force: true });
expect(page.url()).toContain("kitExtras=false");
await page.reload();
expect(page.url()).toContain("kitExtras=false");
await expect(
page.getByRole("switch", { name: /Show sub & special changes/ }),
).not.toBeChecked();
});
});

View File

@ -4,6 +4,7 @@ const config = {
type: true,
},
tags: ["-lintignore"],
ignore: ["scripts/dicts/**"],
entry: [
"app/features/*/routes/**/*.{ts,tsx}",
"migrations/**/*.js",

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Vægt",
"attribute.weight.Fast": "Let",
"attribute.weight.Slow": "Tung",

View File

@ -20,6 +20,7 @@
"pages.vods": "Videoer",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "X-kamp rangliste",
"pages.leaderboards": "Pointtavler",
"pages.links": "Link",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/da/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Gewicht",
"attribute.weight.Fast": "Leicht",
"attribute.weight.Slow": "Schwer",

View File

@ -20,6 +20,7 @@
"pages.vods": "Videos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "",
"pages.leaderboards": "",
"pages.links": "",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/de/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "Weapon to analyze",
"rawParameters": "Raw parameters",
"attribute.weight": "Weight",
"attribute.weight.Fast": "Light",
"attribute.weight.Slow": "Heavy",

View File

@ -20,6 +20,7 @@
"pages.vods": "Videos",
"pages.popularBuilds": "Popular Builds",
"pages.abilityStats": "Ability Stats",
"pages.params": "Parameters",
"pages.xsearch": "Top Search",
"pages.leaderboards": "Rankings",
"pages.links": "Links",
@ -152,6 +153,7 @@
"actions.back": "Back",
"actions.viewAll": "View all",
"actions.hide": "Hide",
"actions.showAll": "Show all",
"actions.settings": "Settings",
"actions.reveal": "Reveal",
"noResults": "No results",
@ -390,8 +392,6 @@
"search.noResults": "No results found",
"search.hint": "Start typing to search",
"search.searching": "Searching...",
"dataCredit.lean": "Data credit: Lean",
"header.parameter": "Parameter",
"weaponArt.title": "Community Art",
"tier.tentative": "Tentative {{tierName}}-tier (based on series history)",
"tier.confirmed": "{{tierName}}-tier tournament",

14
locales/en/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "Parameters",
"tab.patches": "Patch history",
"noPatches": "No tracked balance changes for this weapon yet.",
"patches.showSubSpecial": "Show sub & special changes",
"header.parameter": "Parameter",
"legend.title": "Notes",
"legend.damage": "Damage values are stored ×10, so a value of 450 means 45.0 damage.",
"legend.frames": "Times are measured in frames, where 60 frames = 1 second.",
"legend.powerUp": "Low, Mid and High show the same value with none, a moderate amount, and the maximum amount of the relevant Power Up gear ability equipped (Special Power Up for specials, Sub Power Up for subs).",
"compare.action": "Compare across weapons",
"compare.heading": "{{parameter}} across weapons",
"dataCredit.lean": "Data credit: Lean"
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "Arma a analizar",
"rawParameters": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Ligero",
"attribute.weight.Slow": "Pesado",

View File

@ -20,6 +20,7 @@
"pages.vods": "Vídeos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Top Búsqueda",
"pages.leaderboards": "Clasificaciones",
"pages.links": "Enlaces",
@ -152,6 +153,7 @@
"actions.back": "Atrás",
"actions.viewAll": "",
"actions.hide": "Ocultar",
"actions.showAll": "",
"actions.settings": "Ajustes",
"actions.reveal": "",
"noResults": "Sin resultados",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "Tier {{tierName}} provisional",
"tier.confirmed": "Tier {{tierName}} confirmado",

14
locales/es-ES/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Ligero",
"attribute.weight.Slow": "Pesado",

View File

@ -20,6 +20,7 @@
"pages.vods": "Videos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Búsqueda X 500 mejores",
"pages.leaderboards": "Tablas de posición",
"pages.links": "Enlaces",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/es-US/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Poids",
"attribute.weight.Fast": "Léger",
"attribute.weight.Slow": "Lourd",

View File

@ -20,6 +20,7 @@
"pages.vods": "Vidéos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Top 500",
"pages.leaderboards": "Classements",
"pages.links": "Liens",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/fr-CA/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Poids",
"attribute.weight.Fast": "Léger",
"attribute.weight.Slow": "Lourd",

View File

@ -20,6 +20,7 @@
"pages.vods": "Vidéos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Top 500",
"pages.leaderboards": "Classements",
"pages.links": "Liens",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "Aucun résultats",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/fr-EU/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "משקל",
"attribute.weight.Fast": "קל",
"attribute.weight.Slow": "כבד",

View File

@ -20,6 +20,7 @@
"pages.vods": "סרטונים",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "חיפוש בטופ",
"pages.leaderboards": "לוח תוצאות",
"pages.links": "קישורים",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/he/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Leggero",
"attribute.weight.Slow": "Pesante",

View File

@ -20,6 +20,7 @@
"pages.vods": "Videos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Ricerca Top",
"pages.leaderboards": "Classifiche",
"pages.links": "Links",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/it/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "武器",
"rawParameters": "",
"attribute.weight": "重さ",
"attribute.weight.Fast": "軽量級",
"attribute.weight.Slow": "重量級",

View File

@ -9,10 +9,8 @@
"members": "メンバー",
"results": "結果",
"createMapList": "ステージリストを作成する",
"count.teams_one": "{{count}}チーム",
"count.players_one": "{{count}}名",
"count.teams_other": "{{count}}チーム",
"count.players_other": "{{count}}名",
"count.teams": "{{count}}チーム",
"count.players": "{{count}}名",
"forms.dates": "日",
"forms.bracketUrl": "対戦表 URL",
"forms.discordInvite": "Discord サーバー招待 URL",

View File

@ -20,6 +20,7 @@
"pages.vods": "動画一覧",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "上位プレイヤー検索",
"pages.leaderboards": "スコアボード",
"pages.links": "リンク",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/ja/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "무게",
"attribute.weight.Fast": "가벼움",
"attribute.weight.Slow": "무거움",

View File

@ -20,6 +20,7 @@
"pages.vods": "영상",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "랭킹 검색",
"pages.leaderboards": "랭킹표",
"pages.links": "외부 링크",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/ko/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Gewicht",
"attribute.weight.Fast": "Licht",
"attribute.weight.Slow": "Zwaar",

View File

@ -20,6 +20,7 @@
"pages.vods": "",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "",
"pages.leaderboards": "",
"pages.links": "",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/nl/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Waga",
"attribute.weight.Fast": "Lekka",
"attribute.weight.Slow": "Ciężka",

View File

@ -20,6 +20,7 @@
"pages.vods": "",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "",
"pages.leaderboards": "",
"pages.links": "",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/pl/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Leve",
"attribute.weight.Slow": "Pesado",

View File

@ -20,6 +20,7 @@
"pages.vods": "Vídeos",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Listas dos Top 500",
"pages.leaderboards": "Classificações",
"pages.links": "Links",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/pt-BR/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "",
"rawParameters": "",
"attribute.weight": "Весовая категория",
"attribute.weight.Fast": "Лёгкая",
"attribute.weight.Slow": "Тяжелая",

View File

@ -20,6 +20,7 @@
"pages.vods": "Видео",
"pages.popularBuilds": "",
"pages.abilityStats": "",
"pages.params": "",
"pages.xsearch": "Топ по режиму",
"pages.leaderboards": "Таблицы лидеров",
"pages.links": "Ссылки",
@ -152,6 +153,7 @@
"actions.back": "",
"actions.viewAll": "",
"actions.hide": "",
"actions.showAll": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "Нет результатов",
@ -390,8 +392,6 @@
"search.noResults": "",
"search.hint": "",
"search.searching": "",
"dataCredit.lean": "",
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": "",

14
locales/ru/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -1,5 +1,6 @@
{
"weaponSelect.label": "要分析的武器",
"rawParameters": "",
"attribute.weight": "量级",
"attribute.weight.Fast": "轻量级",
"attribute.weight.Slow": "重量级",

View File

@ -20,6 +20,7 @@
"pages.vods": "视频",
"pages.popularBuilds": "热门配装",
"pages.abilityStats": "装备能力数据",
"pages.params": "",
"pages.xsearch": "X比赛排行榜",
"pages.leaderboards": "排行榜",
"pages.links": "链接",
@ -152,6 +153,7 @@
"actions.back": "返回",
"actions.viewAll": "查看全部",
"actions.hide": "隐藏",
"actions.showAll": "",
"actions.settings": "设置",
"actions.reveal": "显示",
"noResults": "无结果",
@ -390,8 +392,6 @@
"search.noResults": "没有结果",
"search.hint": "开始键入以搜索",
"search.searching": "正在搜索...",
"dataCredit.lean": "数据鸣谢: Lean",
"header.parameter": "属性",
"weaponArt.title": "社区插画",
"tier.tentative": "暂定 {{tierName}} 级别(基于过往历史)",
"tier.confirmed": "{{tierName}} 级别赛事",

14
locales/zh/params.json Normal file
View File

@ -0,0 +1,14 @@
{
"tab.params": "",
"tab.patches": "",
"noPatches": "",
"patches.showSubSpecial": "",
"header.parameter": "",
"legend.title": "",
"legend.damage": "",
"legend.frames": "",
"legend.powerUp": "",
"compare.action": "",
"compare.heading": "",
"dataCredit.lean": ""
}

View File

@ -34,8 +34,7 @@
"checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run typecheck && pnpm run knip && pnpm run check-test-db-migrations",
"setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts",
"i18n:sync": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix",
"knip": "knip",
"sync-weapon-params": "vite-node scripts/sync-weapon-params.ts"
"knip": "knip"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1064.0",

View File

@ -1,9 +1,4 @@
// To run this script you need from https://github.com/Leanny/leanny.github.io
// 1) WeaponInfoMain.json inside dicts
// 2) WeaponInfoSub.json inside dicts
// 3) WeaponInfoSpecial.json inside dicts
// 4) SplPlayer.game__GameParameterTable.json inside dicts
// 5) params (weapon folder) inside dicts
// To run this script drop the https://github.com/Leanny/splat3 repo into scripts/dicts/splat3
import fs from "node:fs";
import path from "node:path";
@ -28,19 +23,25 @@ import {
} from "~/modules/in-game-lists/weapon-ids";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import playersParams from "./dicts/SplPlayer.game__GameParameterTable.json";
import weapons from "./dicts/WeaponInfoMain.json";
import specialWeapons from "./dicts/WeaponInfoSpecial.json";
import subWeapons from "./dicts/WeaponInfoSub.json";
import {
LANG_JSONS_TO_CREATE,
loadLangDicts,
loadSplPlayerParams,
loadWeaponInfoMain,
loadWeaponInfoSpecial,
loadWeaponInfoSub,
translationJsonFolderName,
weaponParamsDir,
} from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const playersParams = loadSplPlayerParams();
const weapons = loadWeaponInfoMain();
const subWeapons = loadWeaponInfoSub();
const specialWeapons = loadWeaponInfoSpecial();
const CURRENT_SEASON = 9;
type MainWeapon = (typeof weapons)[number];
@ -1093,7 +1094,7 @@ function loadWeaponParamsObject(
) {
return JSON.parse(
fs.readFileSync(
path.join(__dirname, "dicts", "weapon", weaponRowIdToFileName(weapon)),
path.join(weaponParamsDir(), weaponRowIdToFileName(weapon)),
"utf8",
),
).GameParameters;

View File

@ -3,11 +3,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { brandIds } from "~/modules/in-game-lists/brand-ids";
import invariant from "~/utils/invariant";
import clothes from "./dicts/GearInfoClothes.json";
import head from "./dicts/GearInfoHead.json";
import shoes from "./dicts/GearInfoShoes.json";
import {
LANG_JSONS_TO_CREATE,
loadGearInfoClothes,
loadGearInfoHead,
loadGearInfoShoes,
loadLangDicts,
translationJsonFolderName,
} from "./utils";
@ -15,6 +15,10 @@ import {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const clothes = loadGearInfoClothes();
const head = loadGearInfoHead();
const shoes = loadGearInfoShoes();
const CURRENT_SEASON = 9;
const OUTPUT_DIR_PATH = path.join(__dirname, "output");

View File

@ -12,19 +12,35 @@ import {
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
// 1) WeaponInfoMain.json inside dicts
// 2) WeaponInfoSub.json inside dicts
// 3) WeaponInfoSpecial.json inside dicts
// 4) misc/spl__DamageRateInfoConfig.pp__CombinationDataTableData.json
import params from "./dicts/spl__DamageRateInfoConfig.pp__CombinationDataTableData.json";
import weapons from "./dicts/WeaponInfoMain.json";
import specialWeapons from "./dicts/WeaponInfoSpecial.json";
import subWeapons from "./dicts/WeaponInfoSub.json";
// To run this script drop the https://github.com/Leanny/splat3 repo into scripts/dicts/splat3
import {
loadDamageRateInfo,
loadWeaponInfoMain,
loadWeaponInfoSpecial,
loadWeaponInfoSub,
PARAMETER_DIR,
} from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const params = loadDamageRateInfo();
const weapons = loadWeaponInfoMain();
const subWeapons = loadWeaponInfoSub();
const specialWeapons = loadWeaponInfoSpecial();
const OUTPUT_DIR_PATH = path.join(__dirname, "output");
const DAMAGE_RATE_CONFIG_FILE_NAME =
"spl__DamageRateInfoConfig.pp__CombinationDataTableData.json";
const HISTORY_OUTPUT_PATH = path.join(
__dirname,
"..",
"app",
"features",
"params",
"data",
"damage-rate-history.json",
);
type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number];
@ -41,6 +57,21 @@ type DamageRateCell = {
DamageRate?: number;
};
type DamageRateConfig = { CellList: Record<string, DamageRateCell> };
type TargetHistory = {
target: string;
current: number;
history: Array<{ version: string; value: number }>;
};
type DamageRateHistoryRow = {
mainWeaponIds: MainWeaponId[];
subWeaponIds: SubWeaponId[];
specialWeaponIds: SpecialWeaponId[];
targets: TargetHistory[];
};
const weaponParamsToWeaponIds = (
params: typeof weapons | typeof subWeapons | typeof specialWeapons,
key: string,
@ -60,75 +91,194 @@ const weaponParamsToWeaponIds = (
const isDamageReceiver = (key: string): key is DamageReceiver =>
(DAMAGE_RECEIVERS as readonly string[]).includes(key);
const result: Record<string, ResultEntry | undefined> = {};
for (const cell of Object.values(params.CellList) as DamageRateCell[]) {
if (!isDamageReceiver(cell.ColumnKey)) continue;
if (!cell.DamageRate) continue;
const mainIdsForRow = (rowKey: string) =>
weaponParamsToWeaponIds(weapons, rowKey).filter((id): id is MainWeaponId =>
(mainWeaponIds as readonly number[]).includes(id),
);
const subIdsForRow = (rowKey: string) =>
weaponParamsToWeaponIds(subWeapons, rowKey).filter((id): id is SubWeaponId =>
(subWeaponIds as readonly number[]).includes(id),
);
const specialIdsForRow = (rowKey: string) =>
weaponParamsToWeaponIds(specialWeapons, rowKey).filter(
(id): id is SpecialWeaponId =>
(specialWeaponIds as readonly number[]).includes(id),
);
if (!result[cell.RowKey]) {
result[cell.RowKey] = {
mainWeaponIds: weaponParamsToWeaponIds(weapons, cell.RowKey).filter(
(id): id is MainWeaponId =>
(mainWeaponIds as readonly number[]).includes(id),
),
subWeaponIds: weaponParamsToWeaponIds(subWeapons, cell.RowKey).filter(
(id): id is SubWeaponId =>
(subWeaponIds as readonly number[]).includes(id),
),
specialWeaponIds: weaponParamsToWeaponIds(
specialWeapons,
cell.RowKey,
).filter((id): id is SpecialWeaponId =>
(specialWeaponIds as readonly number[]).includes(id),
),
rates: [],
};
/**
* Resolves the per-target damage rate of every damage rate info row in a single config dump.
* Only the PvP-relevant receivers are kept and the synthetic launched/Recycled Brella canopy
* targets are derived the same way the live object damage calculator expects them.
*/
const damageRatesByRow = (
config: DamageRateConfig,
): Map<string, Map<string, number>> => {
const result = new Map<string, Map<string, number>>();
for (const cell of Object.values(config.CellList)) {
if (!isDamageReceiver(cell.ColumnKey)) continue;
if (!cell.DamageRate) continue;
let row = result.get(cell.RowKey);
if (!row) {
row = new Map();
result.set(cell.RowKey, row);
}
row.set(cell.ColumnKey, cell.DamageRate);
// launched versions have double health but share the same rate
if (
cell.ColumnKey.includes("BulletUmbrellaCanopyNormal") ||
cell.ColumnKey.includes("BulletUmbrellaCanopyWide")
) {
row.set(`${cell.ColumnKey}_Launched`, cell.DamageRate);
}
// Recycled Brella reuses Splat Brella's special damage rates
if (cell.ColumnKey === "BulletUmbrellaCanopyNormal") {
row.set("BulletShelterCanopyFocus", cell.DamageRate);
row.set("BulletShelterCanopyFocus_Launched", cell.DamageRate);
}
}
const entry = result[cell.RowKey]!;
return result;
};
const result: Record<string, ResultEntry | undefined> = {};
for (const [rowKey, rates] of damageRatesByRow(params)) {
const mainWeaponIdsForRow = mainIdsForRow(rowKey);
const subWeaponIdsForRow = subIdsForRow(rowKey);
const specialWeaponIdsForRow = specialIdsForRow(rowKey);
// if it applies to no PvP weapons, we don't care about it
if (
entry.mainWeaponIds.length === 0 &&
entry.subWeaponIds.length === 0 &&
entry.specialWeaponIds.length === 0 &&
cell.RowKey !== "ObjectEffect_Up"
mainWeaponIdsForRow.length === 0 &&
subWeaponIdsForRow.length === 0 &&
specialWeaponIdsForRow.length === 0 &&
rowKey !== "ObjectEffect_Up"
) {
result[cell.RowKey] = undefined;
continue;
}
entry.rates.push({
target: cell.ColumnKey,
rate: cell.DamageRate,
});
// add a second rate for launched versions, since they have double health
if (
cell.ColumnKey.includes("BulletUmbrellaCanopyNormal") ||
cell.ColumnKey.includes("BulletUmbrellaCanopyWide")
) {
entry.rates.push({
target: `${cell.ColumnKey}_Launched`,
rate: cell.DamageRate,
});
}
// if it has special damage rates for Splat Brella, add the same value for Recycled Brella
if (cell.ColumnKey === "BulletUmbrellaCanopyNormal") {
entry.rates.push({
target: "BulletShelterCanopyFocus",
rate: cell.DamageRate,
});
entry.rates.push({
target: "BulletShelterCanopyFocus_Launched",
rate: cell.DamageRate,
});
}
result[rowKey] = {
mainWeaponIds: mainWeaponIdsForRow,
subWeaponIds: subWeaponIdsForRow,
specialWeaponIds: specialWeaponIdsForRow,
rates: [...rates].map(([target, rate]) => ({ target, rate })),
};
}
fs.writeFileSync(
path.join(OUTPUT_DIR_PATH, "object-dmg.json"),
JSON.stringify(result, null, 2),
);
writeDamageRateHistory();
function versionDirToDisplay(version: string): string {
const num = Number.parseInt(version, 10);
const major = Math.floor(num / 100);
const minor = Math.floor((num % 100) / 10);
const patch = num % 10;
return `${major}.${minor}.${patch}`;
}
/**
* Builds the per-row, per-target damage rate history across every versioned config dump and
* writes it for the params page to surface in its patch history. Only PvP-relevant rows and
* only targets whose rate actually changed at some point are kept, so the output stays small.
*/
function writeDamageRateHistory() {
const versionDirs = fs
.readdirSync(PARAMETER_DIR)
.filter((dir) => /^\d+$/.test(dir))
.sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10));
const ratesByVersion = new Map<string, Map<string, Map<string, number>>>();
for (const version of versionDirs) {
const filePath = path.join(
PARAMETER_DIR,
version,
"misc",
DAMAGE_RATE_CONFIG_FILE_NAME,
);
if (!fs.existsSync(filePath)) continue;
const config = JSON.parse(
fs.readFileSync(filePath, "utf8"),
) as DamageRateConfig;
ratesByVersion.set(version, damageRatesByRow(config));
}
const presentVersions = versionDirs.filter((version) =>
ratesByVersion.has(version),
);
const latestVersion = presentVersions[presentVersions.length - 1];
const rows: Record<string, DamageRateHistoryRow> = {};
for (const [rowKey, latestRates] of ratesByVersion.get(latestVersion) ?? []) {
const mainWeaponIdsForRow = mainIdsForRow(rowKey);
const subWeaponIdsForRow = subIdsForRow(rowKey);
const specialWeaponIdsForRow = specialIdsForRow(rowKey);
if (
mainWeaponIdsForRow.length === 0 &&
subWeaponIdsForRow.length === 0 &&
specialWeaponIdsForRow.length === 0
) {
continue;
}
const targets: TargetHistory[] = [];
for (const [target, current] of latestRates) {
const presentForTarget = presentVersions.filter(
(version) =>
ratesByVersion.get(version)?.get(rowKey)?.get(target) !== undefined,
);
const history: Array<{ version: string; value: number }> = [];
for (let i = 0; i < presentForTarget.length - 1; i++) {
const value = ratesByVersion
.get(presentForTarget[i])!
.get(rowKey)!
.get(target)!;
const nextValue = ratesByVersion
.get(presentForTarget[i + 1])!
.get(rowKey)!
.get(target)!;
if (value !== nextValue) {
history.push({
version: versionDirToDisplay(presentForTarget[i]),
value,
});
}
}
if (history.length > 0) {
targets.push({ target, current, history });
}
}
if (targets.length > 0) {
rows[rowKey] = {
mainWeaponIds: mainWeaponIdsForRow,
subWeaponIds: subWeaponIdsForRow,
specialWeaponIds: specialWeaponIdsForRow,
targets,
};
}
}
fs.writeFileSync(
HISTORY_OUTPUT_PATH,
JSON.stringify(
{
metadata: { versions: presentVersions.map(versionDirToDisplay) },
rows,
},
null,
2,
),
);
}

View File

@ -2,11 +2,13 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { logger } from "~/utils/logger";
import weapons from "./dicts/WeaponInfoMain.json";
import { loadWeaponInfoMain } from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const weapons = loadWeaponInfoMain();
const DIR_PATH_1 = path.join(
__dirname,
"..",

View File

@ -1,22 +1,41 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { logger } from "~/utils/logger";
import weapons from "./dicts/WeaponInfoMain.json";
import {
loadWeaponInfoMain,
loadWeaponInfoSpecial,
loadWeaponInfoSub,
MUSH_DIR,
PARAMETER_DIR,
} from "./utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PARAMETER_DIR = path.join(__dirname, "dicts", "parameter");
const weapons = loadWeaponInfoMain();
const subWeapons = loadWeaponInfoSub();
const specialWeapons = loadWeaponInfoSpecial();
const OUTPUT_DIR = path.join(
__dirname,
"..",
"app",
"features",
"weapons",
"params",
"data",
);
const OUTPUT_FILE = path.join(OUTPUT_DIR, "weapon-params.json");
const OUTPUT_FILE = path.join(OUTPUT_DIR, "all-version-weapon-params.json");
const SUB_OUTPUT_FILE = path.join(OUTPUT_DIR, "all-version-sub-params.json");
const SPECIAL_OUTPUT_FILE = path.join(
OUTPUT_DIR,
"all-version-special-params.json",
);
const WEAPON_TYPES_TO_IGNORE = [
"Mission",
@ -72,6 +91,31 @@ function buildWeaponFileNameToIdMap(): Map<string, number> {
return map;
}
// Sub and special weapons share the per-version `weapon` GameParameterTable dump with main
// weapons, so only the canonical "Versus" entry of each id is kept (Hero/Mission/etc. variants
// of the same weapon are ignored).
function buildSubOrSpecialFileNameToIdMap(
entries: Array<{ Id: number; __RowId: string; Type: string }>,
allowedIds: ReadonlySet<number>,
): Map<string, number> {
const map = new Map<string, number>();
const seenFileNames = new Set<string>();
for (const entry of entries) {
if (entry.Type !== "Versus") continue;
if (!allowedIds.has(entry.Id)) continue;
const fileName = weaponRowIdToFileName(entry.__RowId);
if (!seenFileNames.has(fileName)) {
seenFileNames.add(fileName);
map.set(fileName, entry.Id);
}
}
return map;
}
function stripTypeFields(obj: unknown): unknown {
if (obj === null || typeof obj !== "object") {
return obj;
@ -196,6 +240,133 @@ function mergeWithHistory(
return result;
}
// SpecialPoint lives in WeaponInfoMain (kit data), which is not part of the per-version weapon
// GameParameterTable dump, so it is read per version from the local mush dir.
function collectSpecialPointsByVersion(
sortedVersions: string[],
): Map<number, Map<string, number>> {
const result = new Map<number, Map<string, number>>();
const mainWeaponIdSet = new Set<number>(mainWeaponIds);
for (const version of sortedVersions) {
const filePath = path.join(MUSH_DIR, version, "WeaponInfoMain.json");
if (!fs.existsSync(filePath)) continue;
let entries: MainWeapon[];
try {
entries = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
logger.warn(`Failed to parse ${filePath}`);
continue;
}
for (const weapon of entries) {
if (mainWeaponShouldBeSkipped(weapon)) continue;
if (!mainWeaponIdSet.has(weapon.Id)) continue;
if (typeof weapon.SpecialPoint !== "number") continue;
if (!result.has(weapon.Id)) {
result.set(weapon.Id, new Map());
}
result.get(weapon.Id)!.set(version, weapon.SpecialPoint);
}
}
return result;
}
function buildSpecialPointsHistory(
specialPointsByVersion: Map<number, Map<string, number>>,
sortedVersions: string[],
): Record<
string,
{ current: number; history: Array<{ version: string; value: number }> }
> {
const result: Record<
string,
{ current: number; history: Array<{ version: string; value: number }> }
> = {};
for (const [weaponId, byVersion] of specialPointsByVersion) {
const presentVersions = sortedVersions.filter((v) => byVersion.has(v));
if (presentVersions.length === 0) continue;
const current = byVersion.get(presentVersions[presentVersions.length - 1])!;
const history: Array<{ version: string; value: number }> = [];
for (let i = 0; i < presentVersions.length - 1; i++) {
const value = byVersion.get(presentVersions[i])!;
const nextValue = byVersion.get(presentVersions[i + 1])!;
if (value !== nextValue) {
history.push({
version: parseVersionToDisplay(presentVersions[i]),
value,
});
}
}
result[String(weaponId)] = { current, history };
}
return result;
}
// Reads every per-version `weapon` GameParameterTable dump for the given files and folds the
// historical values of each weapon into its latest params using versioned (`Key@version`) keys.
function buildParamsWithHistory(
fileNameToId: Map<string, number>,
sortedVersions: string[],
): Record<string, Record<string, unknown>> {
const allVersions = new Map<number, Map<string, Record<string, unknown>>>();
for (const version of sortedVersions) {
const weaponDir = path.join(PARAMETER_DIR, version, "weapon");
if (!fs.existsSync(weaponDir)) continue;
for (const file of fs.readdirSync(weaponDir)) {
if (!fileNameToId.has(file)) continue;
const weaponId = fileNameToId.get(file)!;
const filePath = path.join(weaponDir, file);
try {
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
const params = stripTypeFields(content.GameParameters) as Record<
string,
unknown
>;
if (!allVersions.has(weaponId)) {
allVersions.set(weaponId, new Map());
}
allVersions.get(weaponId)!.set(version, params);
} catch {
logger.warn(`Failed to parse ${filePath}`);
}
}
}
const output: Record<string, Record<string, unknown>> = {};
const latestVersion = sortedVersions[sortedVersions.length - 1];
for (const [weaponId, versionParams] of allVersions) {
const latestParams = versionParams.get(latestVersion);
if (!latestParams) continue;
const versionsWithWeapon = sortedVersions.filter((v) =>
versionParams.has(v),
);
output[String(weaponId)] = mergeWithHistory(
latestParams,
versionParams,
versionsWithWeapon,
);
}
return output;
}
async function main() {
logger.info("Starting weapon params sync...");
@ -208,78 +379,62 @@ async function main() {
`Found ${sortedVersions.length} versions: ${sortedVersions.map(parseVersionToDisplay).join(", ")}`,
);
const latestVersion = sortedVersions[sortedVersions.length - 1];
const metadata = {
generatedAt: new Date().toISOString(),
latestVersion: parseVersionToDisplay(latestVersion),
versions: sortedVersions.map(parseVersionToDisplay),
};
const weaponFileNameToId = buildWeaponFileNameToIdMap();
logger.info(`Processing ${weaponFileNameToId.size} unique weapons`);
const outputWeapons = buildParamsWithHistory(
weaponFileNameToId,
sortedVersions,
);
const weaponParamsAllVersions = new Map<
number,
Map<string, Record<string, unknown>>
>();
const specialPoints = buildSpecialPointsHistory(
collectSpecialPointsByVersion(sortedVersions),
sortedVersions,
);
for (const version of sortedVersions) {
const weaponDir = path.join(PARAMETER_DIR, version, "weapon");
if (!fs.existsSync(weaponDir)) continue;
const outputSubWeapons = buildParamsWithHistory(
buildSubOrSpecialFileNameToIdMap(subWeapons, new Set(subWeaponIds)),
sortedVersions,
);
const files = fs.readdirSync(weaponDir);
for (const file of files) {
if (!weaponFileNameToId.has(file)) continue;
const weaponId = weaponFileNameToId.get(file)!;
const filePath = path.join(weaponDir, file);
try {
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
const params = stripTypeFields(content.GameParameters) as Record<
string,
unknown
>;
if (!weaponParamsAllVersions.has(weaponId)) {
weaponParamsAllVersions.set(weaponId, new Map());
}
weaponParamsAllVersions.get(weaponId)!.set(version, params);
} catch {
logger.warn(`Failed to parse ${filePath}`);
}
}
}
const outputWeapons: Record<string, Record<string, unknown>> = {};
const latestVersion = sortedVersions[sortedVersions.length - 1];
for (const [weaponId, versionParams] of weaponParamsAllVersions) {
const latestParams = versionParams.get(latestVersion);
if (!latestParams) continue;
const versionsWithWeapon = sortedVersions.filter((v) =>
versionParams.has(v),
);
const paramsWithHistory = mergeWithHistory(
latestParams,
versionParams,
versionsWithWeapon,
);
outputWeapons[String(weaponId)] = paramsWithHistory;
}
const output = {
metadata: {
generatedAt: new Date().toISOString(),
latestVersion: parseVersionToDisplay(latestVersion),
versions: sortedVersions.map(parseVersionToDisplay),
},
weapons: outputWeapons,
};
const outputSpecialWeapons = buildParamsWithHistory(
buildSubOrSpecialFileNameToIdMap(specialWeapons, new Set(specialWeaponIds)),
sortedVersions,
);
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2));
fs.writeFileSync(
OUTPUT_FILE,
JSON.stringify(
{ metadata, weapons: outputWeapons, specialPoints },
null,
2,
),
);
fs.writeFileSync(
SUB_OUTPUT_FILE,
JSON.stringify({ metadata, weapons: outputSubWeapons }, null, 2),
);
fs.writeFileSync(
SPECIAL_OUTPUT_FILE,
JSON.stringify({ metadata, weapons: outputSpecialWeapons }, null, 2),
);
logger.info(`Written to ${OUTPUT_FILE}`);
logger.info(`Total weapons: ${Object.keys(outputWeapons).length}`);
logger.info(`Total main weapons: ${Object.keys(outputWeapons).length}`);
logger.info(`Total sub weapons: ${Object.keys(outputSubWeapons).length}`);
logger.info(
`Total special weapons: ${Object.keys(outputSpecialWeapons).length}`,
);
}
main().catch((err) => logger.error(err));

View File

@ -1,12 +1,29 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type euEn from "./dicts/langs/EUen.json";
import type euEn from "./dicts/splat3/data/language/EUen.json";
// The splat3 dump exposes a `latest` symlink to the newest version folder, so these loaders never
// need to hard-code a version.
import type gearInfoClothes from "./dicts/splat3/data/mush/latest/GearInfoClothes.json";
import type gearInfoHead from "./dicts/splat3/data/mush/latest/GearInfoHead.json";
import type gearInfoShoes from "./dicts/splat3/data/mush/latest/GearInfoShoes.json";
import type weaponInfoMain from "./dicts/splat3/data/mush/latest/WeaponInfoMain.json";
import type weaponInfoSpecial from "./dicts/splat3/data/mush/latest/WeaponInfoSpecial.json";
import type weaponInfoSub from "./dicts/splat3/data/mush/latest/WeaponInfoSub.json";
import type splPlayer from "./dicts/splat3/data/parameter/latest/misc/SplPlayer.game__GameParameterTable.json";
import type damageRateInfo from "./dicts/splat3/data/parameter/latest/misc/spl__DamageRateInfoConfig.pp__CombinationDataTableData.json";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LANG_DICTS_PATH = path.join(__dirname, "dicts", "langs");
const SPLAT3_DATA_PATH = path.join(__dirname, "dicts", "splat3", "data");
/** Per-version weapon/sub/special `GameParameterTable` dumps, keyed by patch version folder. */
export const PARAMETER_DIR = path.join(SPLAT3_DATA_PATH, "parameter");
/** Per-version `WeaponInfo`/`GearInfo` dumps, keyed by patch version folder. */
export const MUSH_DIR = path.join(SPLAT3_DATA_PATH, "mush");
const LANG_DICTS_PATH = path.join(SPLAT3_DATA_PATH, "language");
export const LANG_JSONS_TO_CREATE = [
"EUen",
@ -47,3 +64,44 @@ export function translationJsonFolderName(langCode: string) {
if (langCode === "USfr") return "fr-CA";
return langCode.slice(2);
}
/** Latest-version directory holding the per-weapon `GameParameterTable` dumps. */
export function weaponParamsDir() {
return path.join(PARAMETER_DIR, "latest", "weapon");
}
export const loadWeaponInfoMain = () =>
loadLatestMushJson<typeof weaponInfoMain>("WeaponInfoMain");
export const loadWeaponInfoSub = () =>
loadLatestMushJson<typeof weaponInfoSub>("WeaponInfoSub");
export const loadWeaponInfoSpecial = () =>
loadLatestMushJson<typeof weaponInfoSpecial>("WeaponInfoSpecial");
export const loadGearInfoClothes = () =>
loadLatestMushJson<typeof gearInfoClothes>("GearInfoClothes");
export const loadGearInfoHead = () =>
loadLatestMushJson<typeof gearInfoHead>("GearInfoHead");
export const loadGearInfoShoes = () =>
loadLatestMushJson<typeof gearInfoShoes>("GearInfoShoes");
export const loadSplPlayerParams = () =>
loadLatestParameterMiscJson<typeof splPlayer>(
"SplPlayer.game__GameParameterTable",
);
export const loadDamageRateInfo = () =>
loadLatestParameterMiscJson<typeof damageRateInfo>(
"spl__DamageRateInfoConfig.pp__CombinationDataTableData",
);
function loadLatestMushJson<T>(fileName: string): T {
return JSON.parse(
fs.readFileSync(path.join(MUSH_DIR, "latest", `${fileName}.json`), "utf8"),
);
}
function loadLatestParameterMiscJson<T>(fileName: string): T {
return JSON.parse(
fs.readFileSync(
path.join(PARAMETER_DIR, "latest", "misc", `${fileName}.json`),
"utf8",
),
);
}