mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-06 04:55:15 -05:00
Weapon params page (#3170)
This commit is contained in:
parent
0460488dc3
commit
abed7fa8bb
|
|
@ -146,7 +146,7 @@
|
|||
}
|
||||
|
||||
.listBox {
|
||||
max-height: 300px;
|
||||
max-height: 325px;
|
||||
overflow-y: auto;
|
||||
padding: var(--s-2);
|
||||
outline: none;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ import { Input } from "~/components/Input";
|
|||
import type { SearchLoaderData } from "~/features/search/routes/search";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
mySlugify,
|
||||
navIconUrl,
|
||||
teamPage,
|
||||
tournamentOrganizationPage,
|
||||
|
|
@ -38,6 +36,7 @@ import {
|
|||
saveRecentWeapon,
|
||||
WeaponDestinationMenu,
|
||||
WeaponResultsList,
|
||||
weaponToSelectedWeapon,
|
||||
} from "./WeaponSearch";
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
|
|
@ -166,9 +165,7 @@ function resolveInitialWeapon(
|
|||
if (Number.isNaN(id)) return null;
|
||||
const name = t(`weapons:MAIN_${id}`);
|
||||
if (!name || name === `MAIN_${id}`) return null;
|
||||
const englishName = t(`weapons:MAIN_${id}`, { lng: "en" });
|
||||
const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { lng: "en" });
|
||||
return { id, name, englishName, slug: mySlugify(slugName) };
|
||||
return weaponToSelectedWeapon(id, t);
|
||||
}
|
||||
|
||||
function GlobalSearchContent({
|
||||
|
|
@ -239,14 +236,7 @@ function GlobalSearchContent({
|
|||
|
||||
const recentWeapons: SelectedWeapon[] =
|
||||
searchType === "weapons"
|
||||
? getRecentWeapons().map((id) => {
|
||||
const name = t(`weapons:MAIN_${id}`);
|
||||
const englishName = t(`weapons:MAIN_${id}`, { lng: "en" });
|
||||
const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, {
|
||||
lng: "en",
|
||||
});
|
||||
return { id, name, englishName, slug: mySlugify(slugName) };
|
||||
})
|
||||
? getRecentWeapons().map((id) => weaponToSelectedWeapon(id, t))
|
||||
: [];
|
||||
|
||||
const handleSelect = (key: React.Key) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { clsx } from "clsx";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Namespace, TFunction } from "i18next";
|
||||
import {
|
||||
Calculator,
|
||||
ChartColumnBig,
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Flame,
|
||||
FlaskConical,
|
||||
ImageIcon,
|
||||
SlidersHorizontal,
|
||||
Users,
|
||||
Videotape,
|
||||
} from "lucide-react";
|
||||
|
|
@ -19,6 +20,7 @@ import { filterWeapon } from "~/modules/in-game-lists/utils";
|
|||
import {
|
||||
canonicalWeaponSplId,
|
||||
mainWeaponIds,
|
||||
weaponIdToBaseWeaponId,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
ANALYZER_URL,
|
||||
|
|
@ -29,6 +31,7 @@ import {
|
|||
weaponBuildPage,
|
||||
weaponBuildPopularPage,
|
||||
weaponBuildStatsPage,
|
||||
weaponParamsPage,
|
||||
} from "~/utils/urls";
|
||||
import styles from "./GlobalSearch.module.css";
|
||||
|
||||
|
|
@ -37,6 +40,7 @@ const WEAPON_DESTINATIONS = [
|
|||
"popular",
|
||||
"stats",
|
||||
"analyzer",
|
||||
"params",
|
||||
"vods",
|
||||
"art",
|
||||
"lfg",
|
||||
|
|
@ -48,6 +52,29 @@ export interface SelectedWeapon {
|
|||
name: string;
|
||||
englishName: string;
|
||||
slug: string;
|
||||
paramsSlug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link SelectedWeapon} for a main weapon id: its localized name plus the English-derived
|
||||
* url slugs (the build pages slug from the weapon's canonical id, the params page slug from its base
|
||||
* id). The caller's `t` must have the `weapons` namespace available.
|
||||
*/
|
||||
export function weaponToSelectedWeapon<Ns extends Namespace>(
|
||||
id: MainWeaponId,
|
||||
t: TFunction<Ns>,
|
||||
): SelectedWeapon {
|
||||
return {
|
||||
id,
|
||||
name: t(`weapons:MAIN_${id}` as never),
|
||||
englishName: t(`weapons:MAIN_${id}` as never, { lng: "en" }),
|
||||
slug: mySlugify(
|
||||
t(`weapons:MAIN_${canonicalWeaponSplId(id)}` as never, { lng: "en" }),
|
||||
),
|
||||
paramsSlug: mySlugify(
|
||||
t(`weapons:MAIN_${weaponIdToBaseWeaponId(id)}` as never, { lng: "en" }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterWeaponResults(
|
||||
|
|
@ -58,24 +85,14 @@ export function filterWeaponResults(
|
|||
|
||||
const matches: SelectedWeapon[] = [];
|
||||
for (const id of mainWeaponIds) {
|
||||
const weaponName = t(`weapons:MAIN_${id}`);
|
||||
const isMatch = filterWeapon({
|
||||
weapon: { type: "MAIN", id },
|
||||
weaponName,
|
||||
weaponName: t(`weapons:MAIN_${id}`),
|
||||
searchTerm: query,
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
const englishName = t(`weapons:MAIN_${id}`, { lng: "en" });
|
||||
const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, {
|
||||
lng: "en",
|
||||
});
|
||||
matches.push({
|
||||
id,
|
||||
name: weaponName,
|
||||
englishName,
|
||||
slug: mySlugify(slugName),
|
||||
});
|
||||
matches.push(weaponToSelectedWeapon(id, t));
|
||||
}
|
||||
|
||||
if (matches.length >= 10) break;
|
||||
|
|
@ -93,6 +110,7 @@ function getWeaponDestinationUrl(
|
|||
popular: weaponBuildPopularPage(weapon.slug),
|
||||
stats: weaponBuildStatsPage(weapon.slug),
|
||||
analyzer: `${ANALYZER_URL}?weapon=${weapon.id}`,
|
||||
params: weaponParamsPage(weapon.paramsSlug),
|
||||
vods: `${VODS_PAGE}?weapon=${weapon.id}`,
|
||||
art: `/art?tab=showcase&tag=${encodeURIComponent(weapon.englishName.toLowerCase())}`,
|
||||
lfg: `${LFG_PAGE}?q=w.${weapon.id}`,
|
||||
|
|
@ -191,6 +209,18 @@ export function WeaponDestinationMenu({
|
|||
</span>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem
|
||||
id="params"
|
||||
href={getWeaponDestinationUrl("params", selectedWeapon)}
|
||||
className={styles.listBoxItem}
|
||||
>
|
||||
<div className={styles.resultItem}>
|
||||
<SlidersHorizontal size={20} />
|
||||
<span className={styles.resultName}>
|
||||
{t("common:pages.params")}
|
||||
</span>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem
|
||||
id="vods"
|
||||
href={getWeaponDestinationUrl("vods", selectedWeapon)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { FlaskConical, SlidersHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction, ShouldRevalidateFunction } from "react-router";
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { Image } from "~/components/Image";
|
||||
import { weaponToSelectedWeapon } from "~/components/layout/WeaponSearch";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { Table } from "~/components/Table";
|
||||
|
|
@ -52,8 +53,9 @@ import {
|
|||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
userNewBuildPage,
|
||||
weaponParamsPage,
|
||||
} from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { LinkButton, SendouButton } from "../../../components/elements/Button";
|
||||
import { SendouPopover } from "../../../components/elements/Popover";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import {
|
||||
|
|
@ -246,7 +248,7 @@ function BuildAnalyzerPage() {
|
|||
<Main>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<div className="stack sm items-center w-full">
|
||||
<div className="stack sm items-start w-full">
|
||||
<div className="w-full">
|
||||
<WeaponSelect
|
||||
label={t("analyzer:weaponSelect.label")}
|
||||
|
|
@ -258,6 +260,16 @@ function BuildAnalyzerPage() {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<LinkButton
|
||||
to={weaponParamsPage(
|
||||
weaponToSelectedWeapon(mainWeaponId, t).paramsSlug,
|
||||
)}
|
||||
variant="minimal"
|
||||
size="small"
|
||||
icon={<SlidersHorizontal />}
|
||||
>
|
||||
{t("analyzer:rawParameters")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="stack md items-center w-full">
|
||||
<div className="w-full">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { differenceInCalendarDays } from "date-fns";
|
||||
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
|
||||
|
||||
export const MAX_BUILD_FILTERS = 6;
|
||||
|
|
@ -6,73 +7,59 @@ export const FILTER_SEARCH_PARAM_KEY = "f";
|
|||
|
||||
type Patch = { patch: string; date: string };
|
||||
|
||||
/**
|
||||
* Every Splatoon 3 game version that introduced weapon parameter changes, newest first,
|
||||
* with its release date (`YYYY-MM-DD`). The version strings match those tracked in the
|
||||
* weapon params data (`metadata.versions`), so this is the single source of patch dates
|
||||
* used by both the builds date filter and the weapon params patch history.
|
||||
*/
|
||||
export const PATCHES: Array<Patch> = [
|
||||
{
|
||||
patch: "11.2.0",
|
||||
date: "2026-06-10",
|
||||
},
|
||||
{
|
||||
patch: "11.1.0",
|
||||
date: "2026-03-18",
|
||||
},
|
||||
{
|
||||
patch: "11.0.0",
|
||||
date: "2026-01-29",
|
||||
},
|
||||
// {
|
||||
// patch: "10.1.0",
|
||||
// date: "2025-09-03",
|
||||
// },
|
||||
// {
|
||||
// patch: "10.0.0",
|
||||
// date: "2025-06-12",
|
||||
// },
|
||||
// {
|
||||
// patch: "9.3.0",
|
||||
// date: "2025-03-13",
|
||||
// },
|
||||
// {
|
||||
// patch: "9.2.0",
|
||||
// date: "2024-11-20",
|
||||
// },
|
||||
// {
|
||||
// patch: "9.0.0",
|
||||
// date: "2024-08-29",
|
||||
// },
|
||||
// {
|
||||
// patch: "8.1.0",
|
||||
// date: "2024-07-17",
|
||||
// },
|
||||
// {
|
||||
// patch: "8.0.0",
|
||||
// date: "2024-05-31",
|
||||
// },
|
||||
// {
|
||||
// patch: "7.2.0",
|
||||
// date: "2024-04-17",
|
||||
// },
|
||||
// {
|
||||
// patch: "7.0.0",
|
||||
// date: "2024-02-21",
|
||||
// },
|
||||
// {
|
||||
// patch: "6.1.0",
|
||||
// date: "2024-01-24",
|
||||
// },
|
||||
// {
|
||||
// patch: "6.0.0",
|
||||
// date: "2023-11-29",
|
||||
// },
|
||||
// {
|
||||
// patch: "5.1.0",
|
||||
// date: "2023-10-17",
|
||||
// },
|
||||
// {
|
||||
// patch: "5.0.0",
|
||||
// date: "2023-08-30",
|
||||
// },
|
||||
{ patch: "11.2.0", date: "2026-06-10" },
|
||||
{ patch: "11.1.0", date: "2026-03-18" },
|
||||
{ patch: "11.0.1", date: "2026-02-10" },
|
||||
{ patch: "11.0.0", date: "2026-01-28" },
|
||||
{ patch: "10.1.0", date: "2025-09-03" },
|
||||
{ patch: "10.0.0", date: "2025-06-11" },
|
||||
{ patch: "9.3.0", date: "2025-03-12" },
|
||||
{ patch: "9.2.0", date: "2024-11-20" },
|
||||
{ patch: "9.1.0", date: "2024-09-11" },
|
||||
{ patch: "9.0.0", date: "2024-08-29" },
|
||||
{ patch: "8.1.0", date: "2024-07-17" },
|
||||
{ patch: "8.0.0", date: "2024-05-30" },
|
||||
{ patch: "7.2.0", date: "2024-04-17" },
|
||||
{ patch: "7.1.0", date: "2024-03-21" },
|
||||
{ patch: "7.0.0", date: "2024-02-21" },
|
||||
{ patch: "6.1.0", date: "2024-01-24" },
|
||||
{ patch: "6.0.0", date: "2023-11-29" },
|
||||
{ patch: "5.2.0", date: "2023-10-17" },
|
||||
{ patch: "5.1.0", date: "2023-09-13" },
|
||||
{ patch: "5.0.0", date: "2023-08-30" },
|
||||
{ patch: "4.1.0", date: "2023-07-26" },
|
||||
{ patch: "4.0.0", date: "2023-05-31" },
|
||||
{ patch: "3.1.0", date: "2023-03-07" },
|
||||
{ patch: "3.0.0", date: "2023-02-28" },
|
||||
{ patch: "2.1.0", date: "2022-12-06" },
|
||||
{ patch: "2.0.0", date: "2022-11-30" },
|
||||
{ patch: "1.2.0", date: "2022-11-16" },
|
||||
{ patch: "1.1.1", date: "2022-09-20" },
|
||||
{ patch: "1.1.0", date: "2022-09-08" },
|
||||
{ patch: "1.0.0", date: "2022-09-09" },
|
||||
{ patch: "0.9.9", date: "2022-09-09" },
|
||||
];
|
||||
|
||||
const RECENT_PATCH_MAX_AGE_IN_DAYS = 365;
|
||||
|
||||
/**
|
||||
* The subset of {@link PATCHES} released within roughly a year of the newest patch. Used
|
||||
* for the builds date filter so its dropdown stays short while always covering the patches
|
||||
* builds are most likely to be filtered against.
|
||||
*/
|
||||
export const RECENT_PATCHES: Array<Patch> = PATCHES.filter(
|
||||
({ date }) =>
|
||||
differenceInCalendarDays(new Date(PATCHES[0].date), new Date(date)) <=
|
||||
RECENT_PATCH_MAX_AGE_IN_DAYS,
|
||||
);
|
||||
|
||||
export const BUILD = {
|
||||
MAX_COUNT: 250,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { dateToYYYYMMDD, isValidDate } from "~/utils/dates";
|
||||
import { PATCHES } from "../builds-constants";
|
||||
import { RECENT_PATCHES } from "../builds-constants";
|
||||
import type {
|
||||
AbilityBuildFilter,
|
||||
BuildFilter,
|
||||
|
|
@ -203,7 +203,9 @@ function DateFilter({
|
|||
});
|
||||
|
||||
const selectValue = () =>
|
||||
PATCHES.some(({ date }) => date === filter.date) ? filter.date : "CUSTOM";
|
||||
RECENT_PATCHES.some(({ date }) => date === filter.date)
|
||||
? filter.date
|
||||
: "CUSTOM";
|
||||
|
||||
// on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays)
|
||||
const oneMonthAgoOnSaturday = new Date();
|
||||
|
|
@ -232,7 +234,7 @@ function DateFilter({
|
|||
})
|
||||
}
|
||||
>
|
||||
{PATCHES.map(({ patch, date: dateString }) => {
|
||||
{RECENT_PATCHES.map(({ patch, date: dateString }) => {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
BUILDS_PAGE_MAX_BUILDS,
|
||||
FILTER_SEARCH_PARAM_KEY,
|
||||
MAX_BUILD_FILTERS,
|
||||
PATCHES,
|
||||
RECENT_PATCHES,
|
||||
} from "../builds-constants";
|
||||
import {
|
||||
type BuildFiltersFromSearchParams,
|
||||
|
|
@ -210,7 +210,7 @@ export default function WeaponsBuildsPage() {
|
|||
: type === "date"
|
||||
? {
|
||||
type: "date",
|
||||
date: PATCHES[0].date,
|
||||
date: RECENT_PATCHES[0].date,
|
||||
}
|
||||
: {
|
||||
type: "mode",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
specialWeaponVariantImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { translateDamageReceiver } from "../calculator-constants";
|
||||
import { useObjectDamage } from "../calculator-hooks";
|
||||
import type { DamageReceiver } from "../calculator-types";
|
||||
import styles from "./object-damage-calculator.module.css";
|
||||
|
|
@ -237,70 +238,6 @@ const damageReceiverAp: Partial<Record<DamageReceiver, JSX.Element>> = {
|
|||
),
|
||||
};
|
||||
|
||||
type ReceiverTranslation =
|
||||
| { key: string }
|
||||
| { weaponKey: string; suffixKey: string };
|
||||
|
||||
const damageReceiverTranslations: Record<DamageReceiver, ReceiverTranslation> =
|
||||
{
|
||||
Chariot: { key: "weapons:SPECIAL_12" },
|
||||
NiceBall_Armor: {
|
||||
weaponKey: "weapons:SPECIAL_6",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.armor",
|
||||
},
|
||||
ShockSonar: { key: "weapons:SPECIAL_7" },
|
||||
GreatBarrier_Barrier: {
|
||||
weaponKey: "weapons:SPECIAL_2",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.shield",
|
||||
},
|
||||
GreatBarrier_WeakPoint: {
|
||||
weaponKey: "weapons:SPECIAL_2",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.weakPoint",
|
||||
},
|
||||
BlowerInhale: {
|
||||
weaponKey: "weapons:SPECIAL_8",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.inhale",
|
||||
},
|
||||
Decoy: { key: "weapons:SPECIAL_16" },
|
||||
BulletPogo: { key: "weapons:SPECIAL_18" },
|
||||
Gachihoko_Barrier: {
|
||||
weaponKey: "game-misc:MODE_LONG_RM",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.shield",
|
||||
},
|
||||
Wsb_Flag: { key: "weapons:SUB_8" },
|
||||
Wsb_Shield: { key: "weapons:SUB_4" },
|
||||
Wsb_Sprinkler: { key: "weapons:SUB_3" },
|
||||
Bomb_TorpedoBullet: { key: "weapons:SUB_13" },
|
||||
BulletUmbrellaCanopyCompact: {
|
||||
weaponKey: "weapons:MAIN_6020",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyNormal: {
|
||||
weaponKey: "weapons:MAIN_6000",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyNormal_Launched: {
|
||||
weaponKey: "weapons:MAIN_6000",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
BulletUmbrellaCanopyWide: {
|
||||
weaponKey: "weapons:MAIN_6010",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletUmbrellaCanopyWide_Launched: {
|
||||
weaponKey: "weapons:MAIN_6010",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
BulletShelterCanopyFocus: {
|
||||
weaponKey: "weapons:MAIN_6030",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopy",
|
||||
},
|
||||
BulletShelterCanopyFocus_Launched: {
|
||||
weaponKey: "weapons:MAIN_6030",
|
||||
suffixKey: "analyzer:damageReceiver.suffix.canopyLaunched",
|
||||
},
|
||||
};
|
||||
|
||||
function DamageReceiversGrid({
|
||||
weapon,
|
||||
damagesToReceivers,
|
||||
|
|
@ -316,13 +253,8 @@ function DamageReceiversGrid({
|
|||
}): JSX.Element {
|
||||
const { t } = useTranslation(["weapons", "analyzer", "common", "game-misc"]);
|
||||
|
||||
const translateReceiver = (receiver: DamageReceiver) => {
|
||||
const config = damageReceiverTranslations[receiver];
|
||||
if ("key" in config) {
|
||||
return t(config.key as any);
|
||||
}
|
||||
return t(config.suffixKey as any, { weapon: t(config.weaponKey as any) });
|
||||
};
|
||||
const translateReceiver = (receiver: DamageReceiver) =>
|
||||
translateDamageReceiver(t, receiver);
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
.bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
min-width: min(420px, 80vw);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--font-2xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.barTrack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.barFill {
|
||||
height: 18px;
|
||||
min-width: 2px;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.barFillCurrent {
|
||||
background-color: var(--color-second);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-xs);
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
}
|
||||
66
app/features/params/components/ParamComparisonDialog.tsx
Normal file
66
app/features/params/components/ParamComparisonDialog.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as R from "remeda";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import type {
|
||||
ParamComparisonEntry,
|
||||
WeaponParamKind,
|
||||
} from "../weapon-params-types";
|
||||
import styles from "./ParamComparisonDialog.module.css";
|
||||
import { WeaponParamImage } from "./WeaponParamsTable";
|
||||
|
||||
/**
|
||||
* Modal with a simple horizontal bar chart comparing one parameter's numeric value across the
|
||||
* currently visible weapons. Bars are scaled relative to the largest absolute value so weapons can
|
||||
* be compared at a glance.
|
||||
*/
|
||||
export function ParamComparisonDialog({
|
||||
kind,
|
||||
label,
|
||||
entries,
|
||||
currentWeaponId,
|
||||
onClose,
|
||||
}: {
|
||||
kind: WeaponParamKind;
|
||||
label: string;
|
||||
entries: ParamComparisonEntry[];
|
||||
currentWeaponId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
const sortedEntries = R.sortBy(entries, [(entry) => entry.value, "desc"]);
|
||||
const maxValue = Math.max(...entries.map((entry) => Math.abs(entry.value)));
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
heading={t("params:compare.heading", { parameter: label })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.bars}>
|
||||
{sortedEntries.map((entry) => (
|
||||
<div key={entry.weaponId} className={styles.row}>
|
||||
<div className={styles.weapon}>
|
||||
<WeaponParamImage kind={kind} id={entry.weaponId} size={28} />
|
||||
<span className={styles.name}>{entry.name}</span>
|
||||
</div>
|
||||
<div className={styles.barTrack}>
|
||||
<div
|
||||
className={clsx(styles.barFill, {
|
||||
[styles.barFillCurrent]: entry.weaponId === currentWeaponId,
|
||||
})}
|
||||
style={{
|
||||
width: `${maxValue === 0 ? 0 : (Math.abs(entry.value) / maxValue) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.value}>
|
||||
{WeaponParams.formatValue(entry.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
44
app/features/params/components/WeaponKits.module.css
Normal file
44
app/features/params/components/WeaponKits.module.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.kits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
background-color: var(--color-bg-high);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-1) var(--s-3);
|
||||
}
|
||||
|
||||
.kitName {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kitGear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.gearLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-rounded);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
50
app/features/params/components/WeaponKits.tsx
Normal file
50
app/features/params/components/WeaponKits.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { mySlugify, weaponParamsPage } from "~/utils/urls";
|
||||
import type { WeaponKitInfo } from "../weapon-params-types";
|
||||
import styles from "./WeaponKits.module.css";
|
||||
|
||||
export function WeaponKits({ kits }: { kits: WeaponKitInfo[] }) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<ul className={styles.kits}>
|
||||
{kits.map((kit) => (
|
||||
<li key={kit.weaponId} className={styles.kit}>
|
||||
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={28} />
|
||||
<span className={styles.kitName}>
|
||||
{t(`weapons:MAIN_${kit.weaponId}`)}
|
||||
</span>
|
||||
<span className={styles.kitGear}>
|
||||
<Link
|
||||
to={weaponParamsPage(
|
||||
mySlugify(t(`weapons:SUB_${kit.subWeaponId}`, { lng: "en" })),
|
||||
)}
|
||||
className={styles.gearLink}
|
||||
>
|
||||
<SubWeaponImage subWeaponId={kit.subWeaponId} size={22} />
|
||||
</Link>
|
||||
<Link
|
||||
to={weaponParamsPage(
|
||||
mySlugify(
|
||||
t(`weapons:SPECIAL_${kit.specialWeaponId}`, { lng: "en" }),
|
||||
),
|
||||
)}
|
||||
className={styles.gearLink}
|
||||
>
|
||||
<SpecialWeaponImage
|
||||
specialWeaponId={kit.specialWeaponId}
|
||||
size={22}
|
||||
/>
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
262
app/features/params/components/WeaponParamsTable.module.css
Normal file
262
app/features/params/components/WeaponParamsTable.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
679
app/features/params/components/WeaponParamsTable.tsx
Normal file
679
app/features/params/components/WeaponParamsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/features/params/components/WeaponParamsView.module.css
Normal file
16
app/features/params/components/WeaponParamsView.module.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dataCredit {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
text-decoration: underline;
|
||||
}
|
||||
109
app/features/params/components/WeaponParamsView.tsx
Normal file
109
app/features/params/components/WeaponParamsView.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
KitPatchHistory,
|
||||
ParsedWeaponParams,
|
||||
SpecialPointWithHistory,
|
||||
WeaponKitInfo,
|
||||
WeaponParamKind,
|
||||
WeaponPatch,
|
||||
} from "../weapon-params-types";
|
||||
import { WeaponKits } from "./WeaponKits";
|
||||
import { WeaponParamsTable } from "./WeaponParamsTable";
|
||||
import styles from "./WeaponParamsView.module.css";
|
||||
import {
|
||||
WeaponPatchHistory,
|
||||
WeaponPatchHistoryByKit,
|
||||
} from "./WeaponPatchHistory";
|
||||
|
||||
export function WeaponParamsView({
|
||||
kind,
|
||||
weaponId,
|
||||
categoryWeaponIds,
|
||||
weaponParams,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
versions,
|
||||
patchHistory,
|
||||
kitPatchHistories,
|
||||
kits,
|
||||
}: {
|
||||
kind: WeaponParamKind;
|
||||
weaponId: number;
|
||||
categoryWeaponIds: number[];
|
||||
weaponParams: Record<string, ParsedWeaponParams>;
|
||||
specialPoints?: Record<string, SpecialPointWithHistory[]>;
|
||||
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
|
||||
versions: string[];
|
||||
patchHistory: WeaponPatch[];
|
||||
kitPatchHistories?: KitPatchHistory[];
|
||||
kits?: WeaponKitInfo[];
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
const [tab, setTab] = useSearchParamState({
|
||||
name: "tab",
|
||||
defaultValue: "params",
|
||||
revive: (value) => (value === "patches" ? "patches" : "params"),
|
||||
});
|
||||
|
||||
const viewedKitPatchCount =
|
||||
kitPatchHistories?.find((kit) => kit.weaponId === weaponId)?.patches
|
||||
.length ?? patchHistory.length;
|
||||
|
||||
return (
|
||||
<Main className={styles.container} bigger>
|
||||
{kits ? <WeaponKits kits={kits} /> : null}
|
||||
<SendouTabs
|
||||
selectedKey={tab}
|
||||
onSelectionChange={(key) => setTab(String(key))}
|
||||
className={styles.tabs}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="params">{t("params:tab.params")}</SendouTab>
|
||||
<SendouTab id="patches" number={viewedKitPatchCount}>
|
||||
{t("params:tab.patches")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="params">
|
||||
<WeaponParamsTable
|
||||
kind={kind}
|
||||
currentWeaponId={weaponId}
|
||||
categoryWeaponIds={categoryWeaponIds}
|
||||
weaponParams={weaponParams}
|
||||
specialPoints={specialPoints}
|
||||
damageMultipliers={damageMultipliers}
|
||||
versions={versions}
|
||||
/>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="patches">
|
||||
{kitPatchHistories && kitPatchHistories.length > 0 ? (
|
||||
<WeaponPatchHistoryByKit
|
||||
kits={kitPatchHistories}
|
||||
defaultWeaponId={weaponId as MainWeaponId}
|
||||
/>
|
||||
) : (
|
||||
<WeaponPatchHistory patches={patchHistory} />
|
||||
)}
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
<a
|
||||
href="https://leanny.github.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.dataCredit}
|
||||
>
|
||||
{t("params:dataCredit.lean")}
|
||||
</a>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
176
app/features/params/components/WeaponPatchHistory.module.css
Normal file
176
app/features/params/components/WeaponPatchHistory.module.css
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
.kitHistory {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-2) var(--s-4);
|
||||
}
|
||||
|
||||
.kitChip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--s-2);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-block: var(--s-3);
|
||||
}
|
||||
}
|
||||
|
||||
.dividerLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
flex: 0 0 auto;
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) var(--s-2);
|
||||
border: 2px solid var(--color-border-high);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.change {
|
||||
min-height: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
font-size: var(--font-2xs);
|
||||
|
||||
&.wide,
|
||||
&.incoming {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&.buff {
|
||||
border-color: var(--color-success);
|
||||
background-color: var(--color-success-low);
|
||||
}
|
||||
|
||||
&.nerf {
|
||||
border-color: var(--color-error);
|
||||
background-color: var(--color-error-low);
|
||||
}
|
||||
}
|
||||
|
||||
.changeName {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.change.wide & {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.changeIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changeValues {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
flex-shrink: 0;
|
||||
font-weight: var(--weight-semi);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.change.wide & {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.attackers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attackerIcons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.attackerSuffix {
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-sm);
|
||||
padding: var(--s-4);
|
||||
}
|
||||
389
app/features/params/components/WeaponPatchHistory.tsx
Normal file
389
app/features/params/components/WeaponPatchHistory.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import {
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import {
|
||||
damageReceiverSuffix,
|
||||
translateDamageReceiver,
|
||||
} from "~/features/object-damage-calculator/calculator-constants";
|
||||
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
|
||||
import {
|
||||
useSearchParamState,
|
||||
useSearchParamStateEncoder,
|
||||
} from "~/hooks/useSearchParamState";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import {
|
||||
DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
SPECIAL_POINTS_PARAM_KEY,
|
||||
} from "../weapon-params-constants";
|
||||
import type {
|
||||
IncomingDamageAttackers,
|
||||
KitPatchHistory,
|
||||
PatchChange,
|
||||
WeaponPatch,
|
||||
} from "../weapon-params-types";
|
||||
import styles from "./WeaponPatchHistory.module.css";
|
||||
|
||||
const PATCH_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
export function WeaponPatchHistory({ patches }: { patches: WeaponPatch[] }) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
if (patches.length === 0) {
|
||||
return <div className={styles.empty}>{t("params:noPatches")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{patches.map((patch) => (
|
||||
<div key={patch.version} className={styles.column}>
|
||||
<PatchColumnHeader version={patch.version} date={patch.date} />
|
||||
<div className={styles.changes}>
|
||||
{patch.changes.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch history of a main weapon shown one kit at a time: the selected kit's main weapon, sub
|
||||
* weapon and special weapon changes are grouped under dividers within the same patch column. Sub
|
||||
* and special weapon changes can be toggled off.
|
||||
*/
|
||||
export function WeaponPatchHistoryByKit({
|
||||
kits,
|
||||
defaultWeaponId,
|
||||
}: {
|
||||
kits: KitPatchHistory[];
|
||||
defaultWeaponId: MainWeaponId;
|
||||
}) {
|
||||
const { t } = useTranslation(["params"]);
|
||||
|
||||
const kitIds = kits.map((kit) => kit.weaponId);
|
||||
|
||||
const [selectedWeaponId, setSelectedWeaponId] =
|
||||
useSearchParamStateEncoder<MainWeaponId>({
|
||||
name: "kit",
|
||||
defaultValue: defaultWeaponId,
|
||||
revive: (value) => {
|
||||
const id = Number(value) as MainWeaponId;
|
||||
return kitIds.includes(id) ? id : undefined;
|
||||
},
|
||||
encode: (value) => String(value),
|
||||
});
|
||||
|
||||
const [showSubSpecial, setShowSubSpecial] = useSearchParamState({
|
||||
name: "kitExtras",
|
||||
defaultValue: true,
|
||||
revive: (value) =>
|
||||
value === "false" ? false : value === "true" ? true : undefined,
|
||||
});
|
||||
|
||||
const selectedKit =
|
||||
kits.find((kit) => kit.weaponId === selectedWeaponId) ?? kits[0];
|
||||
|
||||
const patches = selectedKit.patches
|
||||
.map((patch) => ({
|
||||
...patch,
|
||||
changes: showSubSpecial
|
||||
? patch.changes
|
||||
: patch.changes.filter((change) => change.source === "main"),
|
||||
}))
|
||||
.filter((patch) => patch.changes.length > 0);
|
||||
|
||||
return (
|
||||
<div className={styles.kitHistory}>
|
||||
<div className={styles.controls}>
|
||||
{kits.length > 1 ? (
|
||||
<KitFilter
|
||||
kits={kits}
|
||||
selectedWeaponId={selectedKit.weaponId}
|
||||
onSelect={setSelectedWeaponId}
|
||||
/>
|
||||
) : null}
|
||||
<SendouSwitch isSelected={showSubSpecial} onChange={setShowSubSpecial}>
|
||||
{t("params:patches.showSubSpecial")}
|
||||
</SendouSwitch>
|
||||
</div>
|
||||
{patches.length === 0 ? (
|
||||
<div className={styles.empty}>{t("params:noPatches")}</div>
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
{patches.map((patch) => (
|
||||
<KitPatchColumn
|
||||
key={patch.version}
|
||||
patch={patch}
|
||||
kit={selectedKit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KitFilter({
|
||||
kits,
|
||||
selectedWeaponId,
|
||||
onSelect,
|
||||
}: {
|
||||
kits: KitPatchHistory[];
|
||||
selectedWeaponId: MainWeaponId;
|
||||
onSelect: (weaponId: MainWeaponId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<SendouChipRadioGroup>
|
||||
{kits.map((kit) => (
|
||||
<SendouChipRadio
|
||||
key={kit.weaponId}
|
||||
name="patch-history-kit"
|
||||
value={String(kit.weaponId)}
|
||||
checked={kit.weaponId === selectedWeaponId}
|
||||
onChange={(value) => onSelect(Number(value) as MainWeaponId)}
|
||||
>
|
||||
<span className={styles.kitChip}>
|
||||
<WeaponImage weaponSplId={kit.weaponId} variant="badge" size={20} />
|
||||
{t(`weapons:MAIN_${kit.weaponId}`)}
|
||||
</span>
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function KitPatchColumn({
|
||||
patch,
|
||||
kit,
|
||||
}: {
|
||||
patch: WeaponPatch;
|
||||
kit: KitPatchHistory;
|
||||
}) {
|
||||
const mainChanges = patch.changes.filter(
|
||||
(change) => change.source === "main",
|
||||
);
|
||||
const subChanges = patch.changes.filter((change) => change.source === "sub");
|
||||
const specialChanges = patch.changes.filter(
|
||||
(change) => change.source === "special",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.column}>
|
||||
<PatchColumnHeader version={patch.version} date={patch.date} />
|
||||
<div className={styles.changes}>
|
||||
{mainChanges.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
{subChanges.length > 0 ? (
|
||||
<>
|
||||
<SubWeaponDivider subWeaponId={kit.subWeaponId} />
|
||||
{subChanges.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{specialChanges.length > 0 ? (
|
||||
<>
|
||||
<SpecialWeaponDivider specialWeaponId={kit.specialWeaponId} />
|
||||
{specialChanges.map((change, i) => (
|
||||
<ChangeBadge key={changeKey(change, i)} change={change} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubWeaponDivider({ subWeaponId }: { subWeaponId: SubWeaponId }) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Divider smallText className={styles.divider}>
|
||||
<span className={styles.dividerLabel}>
|
||||
<SubWeaponImage subWeaponId={subWeaponId} size={18} />
|
||||
{t(`weapons:SUB_${subWeaponId}`)}
|
||||
</span>
|
||||
</Divider>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialWeaponDivider({
|
||||
specialWeaponId,
|
||||
}: {
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Divider smallText className={styles.divider}>
|
||||
<span className={styles.dividerLabel}>
|
||||
<SpecialWeaponImage specialWeaponId={specialWeaponId} size={18} />
|
||||
{t(`weapons:SPECIAL_${specialWeaponId}`)}
|
||||
</span>
|
||||
</Divider>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchColumnHeader({
|
||||
version,
|
||||
date,
|
||||
}: {
|
||||
version: string;
|
||||
date: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.version}>{version}</div>
|
||||
{date ? (
|
||||
<LocaleTime
|
||||
date={new Date(date)}
|
||||
options={PATCH_DATE_OPTIONS}
|
||||
className={styles.date}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function changeKey(change: PatchChange, index: number) {
|
||||
return `${change.category}.${change.key}.${change.weaponId ?? ""}.${change.source ?? ""}.${index}`;
|
||||
}
|
||||
|
||||
function ChangeBadge({ change }: { change: PatchChange }) {
|
||||
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
|
||||
|
||||
if (
|
||||
change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY &&
|
||||
change.attackers
|
||||
) {
|
||||
return <IncomingChangeBadge change={change} attackers={change.attackers} />;
|
||||
}
|
||||
|
||||
const isSpecialPoints = change.category === SPECIAL_POINTS_PARAM_KEY;
|
||||
const isDamageMultiplier = change.category === DAMAGE_MULTIPLIER_PARAM_KEY;
|
||||
// Damage falloff curves serialize to long "damage @ distance" lists that need their own line.
|
||||
const isWideValue =
|
||||
typeof change.from === "string" && change.from.includes("@");
|
||||
|
||||
const label = isSpecialPoints
|
||||
? t("analyzer:stat.specialPoints")
|
||||
: isDamageMultiplier
|
||||
? translateDamageReceiver(t, change.key as DamageReceiver)
|
||||
: change.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.change, {
|
||||
[styles.buff]: change.kind === "buff",
|
||||
[styles.nerf]: change.kind === "nerf",
|
||||
[styles.wide]: isWideValue,
|
||||
})}
|
||||
title={
|
||||
isSpecialPoints || isDamageMultiplier
|
||||
? undefined
|
||||
: `${change.category}.${change.key}`
|
||||
}
|
||||
>
|
||||
<span className={styles.changeName}>
|
||||
{isSpecialPoints && change.weaponId ? (
|
||||
<WeaponImage
|
||||
weaponSplId={change.weaponId}
|
||||
variant="badge"
|
||||
size={20}
|
||||
className={styles.changeIcon}
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
</span>
|
||||
<span className={styles.changeValues}>
|
||||
{WeaponParams.formatValue(change.from)}
|
||||
<span className={styles.arrow}>→</span>
|
||||
{WeaponParams.formatValue(change.to)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An incoming damage multiplier change: a set of attacking weapons whose shared damage rate against
|
||||
* the page's sub or special weapon changed. Shows the attacking weapons' icons (with a suffix for
|
||||
* multi-part objects, e.g. a Big Bubbler's shield vs. weak point) and the from→to rate.
|
||||
*/
|
||||
function IncomingChangeBadge({
|
||||
change,
|
||||
attackers,
|
||||
}: {
|
||||
change: PatchChange;
|
||||
attackers: IncomingDamageAttackers;
|
||||
}) {
|
||||
const { t } = useTranslation(["analyzer", "weapons", "game-misc"]);
|
||||
|
||||
const suffix = damageReceiverSuffix(t, change.key as DamageReceiver);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.change, styles.incoming, {
|
||||
[styles.buff]: change.kind === "buff",
|
||||
[styles.nerf]: change.kind === "nerf",
|
||||
})}
|
||||
title={translateDamageReceiver(t, change.key as DamageReceiver)}
|
||||
>
|
||||
<div className={styles.attackers}>
|
||||
<span className={styles.attackerIcons}>
|
||||
{attackers.mainWeaponIds.map((id) => (
|
||||
<WeaponImage
|
||||
key={`m-${id}`}
|
||||
weaponSplId={id}
|
||||
variant="badge"
|
||||
size={20}
|
||||
/>
|
||||
))}
|
||||
{attackers.subWeaponIds.map((id) => (
|
||||
<SubWeaponImage key={`s-${id}`} subWeaponId={id} size={20} />
|
||||
))}
|
||||
{attackers.specialWeaponIds.map((id) => (
|
||||
<SpecialWeaponImage
|
||||
key={`x-${id}`}
|
||||
specialWeaponId={id}
|
||||
size={20}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
{suffix ? (
|
||||
<span className={styles.attackerSuffix}>{suffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={styles.changeValues}>
|
||||
{WeaponParams.formatValue(change.from)}
|
||||
<span className={styles.arrow}>→</span>
|
||||
{WeaponParams.formatValue(change.to)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
app/features/params/core/WeaponParams.test.ts
Normal file
473
app/features/params/core/WeaponParams.test.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
SPECIAL_POINTS_PARAM_KEY,
|
||||
} from "../weapon-params-constants";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
ParsedWeaponParams,
|
||||
} from "../weapon-params-types";
|
||||
import { classifyParamChange } from "./param-directions";
|
||||
import * as WeaponParams from "./WeaponParams";
|
||||
|
||||
const VERSIONS = ["1.0.0", "2.0.0", "3.0.0"];
|
||||
|
||||
const emptyParsed = (weaponId: number): ParsedWeaponParams => ({
|
||||
weaponId,
|
||||
categories: {},
|
||||
});
|
||||
|
||||
const row = (overrides: {
|
||||
mainWeaponIds?: number[];
|
||||
subWeaponIds?: number[];
|
||||
specialWeaponIds?: number[];
|
||||
targets: DamageMultiplierWithHistory[];
|
||||
}) => ({
|
||||
mainWeaponIds: [],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("damageMultipliersForWeapon", () => {
|
||||
it("collects only rows applying to the weapon for the given kind", () => {
|
||||
const rows = {
|
||||
a: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [{ target: "Chariot", current: 2, history: [] }],
|
||||
}),
|
||||
b: row({
|
||||
specialWeaponIds: [12],
|
||||
targets: [{ target: "ShockSonar", current: 2, history: [] }],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
|
||||
|
||||
expect(result.map((m) => m.target)).toEqual(["Chariot"]);
|
||||
});
|
||||
|
||||
it("de-duplicates identical target histories shared across rows", () => {
|
||||
const sharedTarget: DamageMultiplierWithHistory = {
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
};
|
||||
const rows = {
|
||||
bullet: row({ specialWeaponIds: [10], targets: [sharedTarget] }),
|
||||
bombCore: row({
|
||||
specialWeaponIds: [10],
|
||||
targets: [{ ...sharedTarget }],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 10, "special");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("merges several rows of the same target into the most informative entry", () => {
|
||||
const rows = {
|
||||
swing: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [{ target: "Chariot", current: 4.5, history: [] }],
|
||||
}),
|
||||
throwBombCore: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [
|
||||
{
|
||||
target: "Chariot",
|
||||
current: 3.273,
|
||||
history: [
|
||||
{ version: "2.0.0", value: 2 },
|
||||
{ version: "3.0.0", value: 6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].history).toHaveLength(2);
|
||||
expect(result[0].current).toBe(3.273);
|
||||
});
|
||||
|
||||
it("orders entries like DAMAGE_RECEIVERS", () => {
|
||||
const rows = {
|
||||
a: row({
|
||||
specialWeaponIds: [11],
|
||||
targets: [
|
||||
{ target: "Wsb_Shield", current: 2, history: [] },
|
||||
{ target: "Chariot", current: 3, history: [] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = WeaponParams.damageMultipliersForWeapon(rows, 11, "special");
|
||||
|
||||
// Chariot precedes Wsb_Shield in DAMAGE_RECEIVERS
|
||||
expect(result.map((m) => m.target)).toEqual(["Chariot", "Wsb_Shield"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchHistory damage multipliers", () => {
|
||||
const buildWith = (multiplier: DamageMultiplierWithHistory) =>
|
||||
WeaponParams.patchHistory(emptyParsed(11), VERSIONS, [], [multiplier]);
|
||||
|
||||
it("attributes a change to the version after the recorded one and flags a higher rate as a buff", () => {
|
||||
const patches = buildWith({
|
||||
target: "Wsb_Shield",
|
||||
current: 2.2,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
});
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].version).toBe("2.0.0");
|
||||
expect(patches[0].changes).toEqual([
|
||||
{
|
||||
category: DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: "Wsb_Shield",
|
||||
from: 2,
|
||||
to: 2.2,
|
||||
kind: "buff",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags a lower rate as a nerf", () => {
|
||||
const patches = buildWith({
|
||||
target: "NiceBall_Armor",
|
||||
current: 1.82,
|
||||
history: [{ version: "2.0.0", value: 2.6 }],
|
||||
});
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].version).toBe("3.0.0");
|
||||
expect(patches[0].changes[0].kind).toBe("nerf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("incomingDamageMultipliersForWeapon", () => {
|
||||
it("collects other weapons' rates against the weapon's receiver targets", () => {
|
||||
const rows = {
|
||||
fromSpecial: row({
|
||||
specialWeaponIds: [10],
|
||||
targets: [
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
fromMains: row({
|
||||
mainWeaponIds: [200, 201],
|
||||
targets: [
|
||||
{
|
||||
target: "GreatBarrier_WeakPoint",
|
||||
current: 3,
|
||||
history: [{ version: "2.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
unrelated: row({
|
||||
mainWeaponIds: [400],
|
||||
targets: [{ target: "Chariot", current: 2, history: [] }],
|
||||
}),
|
||||
};
|
||||
|
||||
// special id 2 is Big Bubbler (GreatBarrier_Barrier + GreatBarrier_WeakPoint)
|
||||
const result = WeaponParams.incomingDamageMultipliersForWeapon(
|
||||
rows,
|
||||
2,
|
||||
"special",
|
||||
);
|
||||
|
||||
expect(result.map((m) => m.target)).toEqual([
|
||||
"GreatBarrier_Barrier",
|
||||
"GreatBarrier_WeakPoint",
|
||||
]);
|
||||
expect(result[1].attackers.mainWeaponIds).toEqual([200, 201]);
|
||||
});
|
||||
|
||||
it("de-duplicates the same attacker group and target across rows", () => {
|
||||
const target = {
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
};
|
||||
const rows = {
|
||||
bullet: row({ specialWeaponIds: [10], targets: [target] }),
|
||||
bombCore: row({ specialWeaponIds: [10], targets: [{ ...target }] }),
|
||||
};
|
||||
|
||||
const result = WeaponParams.incomingDamageMultipliersForWeapon(
|
||||
rows,
|
||||
2,
|
||||
"special",
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns nothing for a weapon that is not a damageable object", () => {
|
||||
const rows = {
|
||||
a: row({
|
||||
specialWeaponIds: [10],
|
||||
targets: [
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
current: 1,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
// special id 1 (Trizooka) is not in INCOMING_DAMAGE_RECEIVERS
|
||||
expect(
|
||||
WeaponParams.incomingDamageMultipliersForWeapon(rows, 1, "special"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchHistory incoming damage multipliers", () => {
|
||||
it("flags a higher incoming rate as a nerf to the defending weapon and carries the attackers", () => {
|
||||
const patches = WeaponParams.patchHistory(
|
||||
emptyParsed(2),
|
||||
VERSIONS,
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
attackers: {
|
||||
mainWeaponIds: [200],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
},
|
||||
current: 2.8,
|
||||
history: [{ version: "1.0.0", value: 1.4 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].version).toBe("2.0.0");
|
||||
expect(patches[0].changes[0]).toMatchObject({
|
||||
category: INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: "GreatBarrier_Barrier",
|
||||
from: 1.4,
|
||||
to: 2.8,
|
||||
kind: "nerf",
|
||||
attackers: { mainWeaponIds: [200] },
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a lower incoming rate as a buff to the defending weapon", () => {
|
||||
const patches = WeaponParams.patchHistory(
|
||||
emptyParsed(2),
|
||||
VERSIONS,
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
target: "GreatBarrier_Barrier",
|
||||
attackers: {
|
||||
mainWeaponIds: [200],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
},
|
||||
current: 1.4,
|
||||
history: [{ version: "1.0.0", value: 2.8 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(patches[0].changes[0].kind).toBe("buff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse damage falloff curves", () => {
|
||||
it("serializes a DistanceDamage array into a scaled damage @ distance string", () => {
|
||||
const parsed = WeaponParams.parse(
|
||||
0,
|
||||
{
|
||||
BlastParam: {
|
||||
DistanceDamage: [
|
||||
{ Damage: 1800, Distance: 3.6 },
|
||||
{ Damage: 300, Distance: 7 },
|
||||
],
|
||||
},
|
||||
},
|
||||
VERSIONS,
|
||||
);
|
||||
|
||||
expect(parsed.categories.BlastParam.DistanceDamage.current).toBe(
|
||||
"180 @ 3.6, 30 @ 7",
|
||||
);
|
||||
});
|
||||
|
||||
it("flattens nested breakpoint arrays", () => {
|
||||
const parsed = WeaponParams.parse(
|
||||
0,
|
||||
{
|
||||
BlastParam: {
|
||||
DistanceDamage: [
|
||||
[{ Damage: 1800, Distance: 3.6 }],
|
||||
[{ Damage: 300, Distance: 7 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
VERSIONS,
|
||||
);
|
||||
|
||||
expect(parsed.categories.BlastParam.DistanceDamage.current).toBe(
|
||||
"180 @ 3.6, 30 @ 7",
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks per-version history of a damage falloff curve", () => {
|
||||
const parsed = WeaponParams.parse(
|
||||
0,
|
||||
{
|
||||
BlastParam: {
|
||||
DistanceDamage: [{ Damage: 600, Distance: 4 }],
|
||||
"DistanceDamage@2.0.0": [{ Damage: 400, Distance: 4 }],
|
||||
},
|
||||
},
|
||||
VERSIONS,
|
||||
);
|
||||
|
||||
expect(parsed.categories.BlastParam.DistanceDamage.history).toEqual([
|
||||
{ version: "2.0.0", value: "40 @ 4" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyParamChange damage falloff curves", () => {
|
||||
it("flags higher damage as a buff", () => {
|
||||
expect(
|
||||
classifyParamChange("BlastParam", "DistanceDamage", "40 @ 4", "60 @ 4"),
|
||||
).toBe("buff");
|
||||
});
|
||||
|
||||
it("flags lower damage as a nerf", () => {
|
||||
expect(
|
||||
classifyParamChange("BlastParam", "DistanceDamage", "60 @ 4", "40 @ 4"),
|
||||
).toBe("nerf");
|
||||
});
|
||||
|
||||
it("flags longer reach at the same damage as a buff", () => {
|
||||
expect(
|
||||
classifyParamChange(
|
||||
"BlastParam",
|
||||
"DistanceDamage",
|
||||
"70 @ 0.94, 50 @ 3.3",
|
||||
"70 @ 1.01, 50 @ 3.37",
|
||||
),
|
||||
).toBe("buff");
|
||||
});
|
||||
|
||||
it("flags shorter reach at the same damage as a nerf", () => {
|
||||
expect(
|
||||
classifyParamChange(
|
||||
"BlastParam",
|
||||
"DistanceDamage",
|
||||
"70 @ 1.01, 50 @ 3.37",
|
||||
"70 @ 0.975, 50 @ 3.37",
|
||||
),
|
||||
).toBe("nerf");
|
||||
});
|
||||
|
||||
it("is neutral when damage rises but reach shrinks", () => {
|
||||
expect(
|
||||
classifyParamChange("BlastParam", "DistanceDamage", "60 @ 4", "70 @ 3.5"),
|
||||
).toBe("neutral");
|
||||
});
|
||||
|
||||
it("is neutral when the curve gains or loses a breakpoint", () => {
|
||||
expect(
|
||||
classifyParamChange(
|
||||
"BlastParam",
|
||||
"DistanceDamage",
|
||||
"60 @ 4",
|
||||
"60 @ 4, 30 @ 8",
|
||||
),
|
||||
).toBe("neutral");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kitPatchHistories", () => {
|
||||
const kitHistory = () =>
|
||||
WeaponParams.kitPatchHistories({
|
||||
mainParsed: emptyParsed(11),
|
||||
versions: VERSIONS,
|
||||
kits: [{ weaponId: 11, subWeaponId: 1, specialWeaponId: 2 }],
|
||||
specialPointsByKit: {
|
||||
"11": {
|
||||
weaponId: 11,
|
||||
current: 180,
|
||||
history: [{ version: "1.0.0", value: 200 }],
|
||||
},
|
||||
},
|
||||
mainDamageMultipliers: [
|
||||
{
|
||||
target: "Wsb_Shield",
|
||||
current: 2.2,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
subParams: { "1": emptyParsed(1) },
|
||||
subDamageMultipliers: {
|
||||
"1": [
|
||||
{
|
||||
target: "Chariot",
|
||||
current: 3,
|
||||
history: [{ version: "1.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
subIncomingDamageMultipliers: {},
|
||||
specialParams: { "2": emptyParsed(2) },
|
||||
specialDamageMultipliers: {
|
||||
"2": [
|
||||
{
|
||||
target: "NiceBall_Armor",
|
||||
current: 1.5,
|
||||
history: [{ version: "2.0.0", value: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
specialIncomingDamageMultipliers: {},
|
||||
});
|
||||
|
||||
it("folds the kit's main, sub and special weapon changes into one descending history", () => {
|
||||
const [history] = kitHistory();
|
||||
|
||||
expect(history.weaponId).toBe(11);
|
||||
expect(history.patches.map((patch) => patch.version)).toEqual([
|
||||
"3.0.0",
|
||||
"2.0.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tags each change with its source and groups main before sub before special", () => {
|
||||
const [history] = kitHistory();
|
||||
|
||||
const v2 = history.patches.find((patch) => patch.version === "2.0.0")!;
|
||||
// special points + main damage rate (both main), then the sub weapon's damage rate
|
||||
expect(v2.changes.map((change) => change.source)).toEqual([
|
||||
"main",
|
||||
"main",
|
||||
"sub",
|
||||
]);
|
||||
expect(v2.changes[0].category).toBe(SPECIAL_POINTS_PARAM_KEY);
|
||||
|
||||
const v3 = history.patches.find((patch) => patch.version === "3.0.0")!;
|
||||
expect(v3.changes.map((change) => change.source)).toEqual(["special"]);
|
||||
});
|
||||
});
|
||||
774
app/features/params/core/WeaponParams.ts
Normal file
774
app/features/params/core/WeaponParams.ts
Normal file
|
|
@ -0,0 +1,774 @@
|
|||
import { PATCHES } from "~/features/builds/builds-constants";
|
||||
import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
weaponCategories,
|
||||
weaponIdToBaseWeaponId,
|
||||
weaponIdToType,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
INCOMING_DAMAGE_RECEIVERS,
|
||||
SPECIAL_POINTS_PARAM_KEY,
|
||||
} from "../weapon-params-constants";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
IncomingDamageAttackers,
|
||||
IncomingDamageMultiplierWithHistory,
|
||||
KitPatchHistory,
|
||||
ParamDefinition,
|
||||
ParamValueWithHistory,
|
||||
ParsedWeaponParams,
|
||||
PatchChange,
|
||||
SpecialPointWithHistory,
|
||||
WeaponKitInfo,
|
||||
WeaponParamKind,
|
||||
WeaponPatch,
|
||||
} from "../weapon-params-types";
|
||||
import { classifyParamChange } from "./param-directions";
|
||||
|
||||
/**
|
||||
* Shape of the committed `all-version-*-params.json` data files: a map of weapon id to its raw
|
||||
* per-version params, the ordered list of tracked game versions, and (weapons only) special
|
||||
* points history.
|
||||
*/
|
||||
export interface AllVersionParams {
|
||||
metadata: { versions: string[] };
|
||||
weapons: Record<string, Record<string, Record<string, unknown>>>;
|
||||
specialPoints?: Record<
|
||||
string,
|
||||
{ history: Array<{ version: string; value: number }> }
|
||||
>;
|
||||
}
|
||||
|
||||
function parseParamKey(key: string): {
|
||||
baseKey: string;
|
||||
version: string | null;
|
||||
} {
|
||||
const atIndex = key.indexOf("@");
|
||||
if (atIndex === -1) {
|
||||
return { baseKey: key, version: null };
|
||||
}
|
||||
return {
|
||||
baseKey: key.slice(0, atIndex),
|
||||
version: key.slice(atIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
interface DistanceDamageBreakpoint {
|
||||
Damage: number;
|
||||
Distance: number;
|
||||
}
|
||||
|
||||
function isDistanceDamageBreakpoint(
|
||||
value: unknown,
|
||||
): value is DistanceDamageBreakpoint {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as DistanceDamageBreakpoint).Damage === "number" &&
|
||||
typeof (value as DistanceDamageBreakpoint).Distance === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a damage falloff curve: an array of {@link DistanceDamageBreakpoint}, with
|
||||
* each entry possibly being a nested array of breakpoints (e.g. fizzy bomb bounces).
|
||||
*/
|
||||
function isDistanceDamageArray(
|
||||
value: unknown[],
|
||||
): value is Array<DistanceDamageBreakpoint | DistanceDamageBreakpoint[]> {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(el) =>
|
||||
isDistanceDamageBreakpoint(el) ||
|
||||
(Array.isArray(el) &&
|
||||
el.length > 0 &&
|
||||
el.every(isDistanceDamageBreakpoint)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a damage falloff curve into a compact `"<damage> @ <distance>"` string (damage
|
||||
* scaled to displayed HP, i.e. divided by 10) so its per-version changes flow through the same
|
||||
* scalar param pipeline as plain values. Nested breakpoint arrays are flattened.
|
||||
*/
|
||||
function formatDistanceDamageArray(
|
||||
value: Array<DistanceDamageBreakpoint | DistanceDamageBreakpoint[]>,
|
||||
): string {
|
||||
return value
|
||||
.flat()
|
||||
.map(
|
||||
(breakpoint) =>
|
||||
`${formatValue(breakpoint.Damage / 10)} @ ${formatValue(breakpoint.Distance)}`,
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function flattenScalarParams(
|
||||
params: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): Array<[string, number | string]> {
|
||||
const result: Array<[string, number | string]> = [];
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === "number" || typeof value === "string") {
|
||||
result.push([fullKey, value]);
|
||||
} else if (Array.isArray(value)) {
|
||||
// Damage falloff curves and arrays of plain numbers/strings (e.g.
|
||||
// SplashSpawnParam.ForceSpawnNearestAddNumArray) are kept as a single joined string so
|
||||
// their per-version changes still show up. Other arrays of objects are too structured
|
||||
// to represent this way and are skipped.
|
||||
if (isDistanceDamageArray(value)) {
|
||||
result.push([fullKey, formatDistanceDamageArray(value)]);
|
||||
} else if (
|
||||
value.length > 0 &&
|
||||
value.every((el) => typeof el === "number" || typeof el === "string")
|
||||
) {
|
||||
result.push([
|
||||
fullKey,
|
||||
`[${value.map((el) => formatValue(el)).join(", ")}]`,
|
||||
]);
|
||||
}
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
result.push(
|
||||
...flattenScalarParams(value as Record<string, unknown>, fullKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single weapon's raw per-version params into the {@link ParsedWeaponParams} shape: each
|
||||
* parameter's current value plus its tracked history, grouped by category.
|
||||
*/
|
||||
export function parse(
|
||||
weaponId: number,
|
||||
rawParams: Record<string, Record<string, unknown>>,
|
||||
versions: string[],
|
||||
): ParsedWeaponParams {
|
||||
const categories: Record<string, Record<string, ParamValueWithHistory>> = {};
|
||||
|
||||
for (const [categoryName, categoryParams] of Object.entries(rawParams)) {
|
||||
if (
|
||||
typeof categoryParams !== "object" ||
|
||||
categoryParams === null ||
|
||||
Object.keys(categoryParams).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedParams: Record<string, ParamValueWithHistory> = {};
|
||||
const paramHistory: Record<
|
||||
string,
|
||||
{ current: number | string; versions: Map<string, number | string> }
|
||||
> = {};
|
||||
|
||||
for (const [key, value] of flattenScalarParams(categoryParams)) {
|
||||
const { baseKey, version } = parseParamKey(key);
|
||||
|
||||
if (!paramHistory[baseKey]) {
|
||||
paramHistory[baseKey] = {
|
||||
current: value,
|
||||
versions: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
if (version === null) {
|
||||
paramHistory[baseKey].current = value;
|
||||
} else {
|
||||
paramHistory[baseKey].versions.set(version, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [baseKey, data] of Object.entries(paramHistory)) {
|
||||
const history: Array<{ version: string; value: number | string }> = [];
|
||||
|
||||
for (const version of versions) {
|
||||
const historicalValue = data.versions.get(version);
|
||||
if (historicalValue !== undefined) {
|
||||
history.push({ version, value: historicalValue });
|
||||
}
|
||||
}
|
||||
|
||||
parsedParams[baseKey] = {
|
||||
current: data.current,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(parsedParams).length > 0) {
|
||||
categories[categoryName] = parsedParams;
|
||||
}
|
||||
}
|
||||
|
||||
return { weaponId, categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the params of every given weapon id from a static all-version params data file, keyed by
|
||||
* weapon id (as a string). Ids with no entry in the data are skipped. `toDataKey` maps a weapon id
|
||||
* to the id its params are stored under — main weapons share params with their base weapon, while
|
||||
* subs and specials use their own id (the default identity mapping).
|
||||
*/
|
||||
export function parseMany<Id extends number>(
|
||||
ids: readonly Id[],
|
||||
data: AllVersionParams,
|
||||
toDataKey: (id: Id) => number = (id) => id,
|
||||
): Record<string, ParsedWeaponParams> {
|
||||
const result: Record<string, ParsedWeaponParams> = {};
|
||||
|
||||
for (const id of ids) {
|
||||
const rawParams = data.weapons[String(toDataKey(id))];
|
||||
if (rawParams) {
|
||||
result[String(id)] = parse(id, rawParams, data.metadata.versions);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every distinct `${category}.${key}` parameter present across the given weapons, sorted
|
||||
* by category then key, for use as the comparison table's row definitions.
|
||||
*/
|
||||
export function allParamKeys(
|
||||
weaponParams: Record<string, ParsedWeaponParams>,
|
||||
): ParamDefinition[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const definitions: ParamDefinition[] = [];
|
||||
|
||||
for (const parsed of Object.values(weaponParams)) {
|
||||
for (const [category, params] of Object.entries(parsed.categories)) {
|
||||
for (const key of Object.keys(params)) {
|
||||
const fullKey = `${category}.${key}`;
|
||||
if (!seenKeys.has(fullKey)) {
|
||||
seenKeys.add(fullKey);
|
||||
definitions.push({ category, key, fullKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
definitions.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function getWeaponCategory(weaponId: MainWeaponId) {
|
||||
return weaponCategories.find((cat) =>
|
||||
(cat.weaponIds as readonly number[]).includes(weaponId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base main weapon ids of the given weapon's category, used as the columns its params
|
||||
* are compared against. A non-base weapon is kept first, followed by the other base weapons.
|
||||
*/
|
||||
export function categoryWeaponIds(weaponId: MainWeaponId): MainWeaponId[] {
|
||||
const category = getWeaponCategory(weaponId);
|
||||
if (!category) {
|
||||
return [weaponId];
|
||||
}
|
||||
|
||||
const baseWeapons = (category.weaponIds as readonly MainWeaponId[]).filter(
|
||||
(id) => weaponIdToType(id) === "BASE",
|
||||
);
|
||||
|
||||
if (baseWeapons.includes(weaponId)) {
|
||||
return baseWeapons;
|
||||
}
|
||||
|
||||
const currentWeaponBaseId = weaponIdToBaseWeaponId(weaponId);
|
||||
return [weaponId, ...baseWeapons.filter((id) => id !== currentWeaponBaseId)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the main weapon ids that are kit siblings of the given weapon, i.e. they share
|
||||
* the same base weapon (e.g. a weapon and its alternate kit) but excluding cosmetic alt
|
||||
* skins. The returned list includes the given weapon itself.
|
||||
*/
|
||||
export function kitSiblingIds(weaponId: MainWeaponId): MainWeaponId[] {
|
||||
const baseId = weaponIdToBaseWeaponId(weaponId);
|
||||
return mainWeaponIds.filter(
|
||||
(id) =>
|
||||
weaponIdToBaseWeaponId(id) === baseId &&
|
||||
weaponIdToType(id) !== "ALT_SKIN",
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the given parameter has any tracked per-version history. */
|
||||
export function hasHistory(param: ParamValueWithHistory): boolean {
|
||||
return param.history.length > 0;
|
||||
}
|
||||
|
||||
interface DamageRateHistoryRow {
|
||||
mainWeaponIds: number[];
|
||||
subWeaponIds: number[];
|
||||
specialWeaponIds: number[];
|
||||
targets: DamageMultiplierWithHistory[];
|
||||
}
|
||||
|
||||
const DAMAGE_RECEIVER_ORDER = new Map(
|
||||
DAMAGE_RECEIVERS.map((receiver, i) => [receiver as string, i]),
|
||||
);
|
||||
|
||||
const EMPTY_ATTACKERS: IncomingDamageAttackers = {
|
||||
mainWeaponIds: [],
|
||||
subWeaponIds: [],
|
||||
specialWeaponIds: [],
|
||||
};
|
||||
|
||||
/** Whether `candidate` is a better single representative of a target than the `current` pick. */
|
||||
function isMoreInformativeMultiplier(
|
||||
candidate: DamageMultiplierWithHistory,
|
||||
current: DamageMultiplierWithHistory,
|
||||
): boolean {
|
||||
if (candidate.history.length !== current.history.length) {
|
||||
return candidate.history.length > current.history.length;
|
||||
}
|
||||
return candidate.current > current.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the damage multiplier history of every damage rate row that applies to the given
|
||||
* weapon, reduced to a single entry per object target. A weapon can map to several rows (e.g.
|
||||
* different attacks) that share the same target; the most informative one (longest tracked
|
||||
* history, then highest current rate) is kept. Entries are ordered like {@link DAMAGE_RECEIVERS}.
|
||||
*/
|
||||
export function damageMultipliersForWeapon(
|
||||
rows: Record<string, DamageRateHistoryRow>,
|
||||
weaponId: number,
|
||||
kind: WeaponParamKind,
|
||||
): DamageMultiplierWithHistory[] {
|
||||
const applies = (row: DamageRateHistoryRow) => {
|
||||
if (kind === "sub") return row.subWeaponIds.includes(weaponId);
|
||||
if (kind === "special") return row.specialWeaponIds.includes(weaponId);
|
||||
return (
|
||||
row.mainWeaponIds.includes(weaponId) ||
|
||||
row.mainWeaponIds.includes(
|
||||
weaponIdToBaseWeaponId(weaponId as MainWeaponId),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const byTarget = new Map<string, DamageMultiplierWithHistory>();
|
||||
|
||||
for (const row of Object.values(rows)) {
|
||||
if (!applies(row)) continue;
|
||||
for (const target of row.targets) {
|
||||
const existing = byTarget.get(target.target);
|
||||
if (!existing || isMoreInformativeMultiplier(target, existing)) {
|
||||
byTarget.set(target.target, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byTarget.values()].sort(
|
||||
(a, b) =>
|
||||
(DAMAGE_RECEIVER_ORDER.get(a.target) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(DAMAGE_RECEIVER_ORDER.get(b.target) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable identifier for a group of attacking weapons, used to de-duplicate incoming entries. */
|
||||
function attackerGroupKey(attackers: IncomingDamageAttackers): string {
|
||||
const part = (ids: number[]) => [...ids].sort((a, b) => a - b).join(",");
|
||||
return `m${part(attackers.mainWeaponIds)};s${part(attackers.subWeaponIds)};x${part(attackers.specialWeaponIds)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects, for the given sub or special weapon (which must itself be a damageable object), the
|
||||
* history of every *other* weapon's damage multiplier against it. Each entry is one group of
|
||||
* attacking weapons that shared a rate change against one of the weapon's receiver targets; per
|
||||
* (attacker group, target) the most informative entry (longest history, then highest rate) is
|
||||
* kept. Entries are ordered like {@link DAMAGE_RECEIVERS}, then by attacker group.
|
||||
*/
|
||||
export function incomingDamageMultipliersForWeapon(
|
||||
rows: Record<string, DamageRateHistoryRow>,
|
||||
weaponId: number,
|
||||
kind: "sub" | "special",
|
||||
): IncomingDamageMultiplierWithHistory[] {
|
||||
const receiverTargets = INCOMING_DAMAGE_RECEIVERS[kind][weaponId];
|
||||
if (!receiverTargets) return [];
|
||||
const targetSet = new Set<string>(receiverTargets);
|
||||
|
||||
const byKey = new Map<string, IncomingDamageMultiplierWithHistory>();
|
||||
|
||||
for (const row of Object.values(rows)) {
|
||||
const attackers: IncomingDamageAttackers = {
|
||||
mainWeaponIds:
|
||||
row.mainWeaponIds as IncomingDamageAttackers["mainWeaponIds"],
|
||||
subWeaponIds: row.subWeaponIds as IncomingDamageAttackers["subWeaponIds"],
|
||||
specialWeaponIds:
|
||||
row.specialWeaponIds as IncomingDamageAttackers["specialWeaponIds"],
|
||||
};
|
||||
const attackerKey = attackerGroupKey(attackers);
|
||||
|
||||
for (const target of row.targets) {
|
||||
if (!targetSet.has(target.target)) continue;
|
||||
|
||||
const key = `${attackerKey}|${target.target}`;
|
||||
const existing = byKey.get(key);
|
||||
if (!existing || isMoreInformativeMultiplier(target, existing)) {
|
||||
byKey.set(key, {
|
||||
target: target.target,
|
||||
attackers,
|
||||
current: target.current,
|
||||
history: target.history,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byKey.values()].sort((a, b) => {
|
||||
const order =
|
||||
(DAMAGE_RECEIVER_ORDER.get(a.target) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(DAMAGE_RECEIVER_ORDER.get(b.target) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (order !== 0) return order;
|
||||
return attackerGroupKey(a.attackers).localeCompare(
|
||||
attackerGroupKey(b.attackers),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function changesFromHistory(
|
||||
history: Array<{ version: string; value: number | string }>,
|
||||
current: number | string,
|
||||
versions: string[],
|
||||
versionIndex: Map<string, number>,
|
||||
): Array<{ patchVersion: string; from: number | string; to: number | string }> {
|
||||
const result: Array<{
|
||||
patchVersion: string;
|
||||
from: number | string;
|
||||
to: number | string;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const { version, value: from } = history[i];
|
||||
const to = i < history.length - 1 ? history[i + 1].value : current;
|
||||
|
||||
// A recorded value is the value *before* a change, so the change took effect at the
|
||||
// next tracked game version.
|
||||
const recordedIndex = versionIndex.get(version);
|
||||
if (recordedIndex === undefined) continue;
|
||||
const patchVersion = versions[recordedIndex + 1];
|
||||
if (!patchVersion) continue;
|
||||
|
||||
result.push({ patchVersion, from, to });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups every tracked parameter change of a single weapon by the game version (patch) that
|
||||
* introduced it. Optionally folds the weapon's special points history into the same grouping.
|
||||
*
|
||||
* Within each patch the changes are sorted with special points first, then alphabetically by
|
||||
* category and key.
|
||||
*/
|
||||
function computeWeaponPatchChanges(
|
||||
parsed: ParsedWeaponParams,
|
||||
versions: string[],
|
||||
specialPoints?: SpecialPointWithHistory[],
|
||||
damageMultipliers?: DamageMultiplierWithHistory[],
|
||||
source?: WeaponParamKind,
|
||||
incomingDamageMultipliers?: IncomingDamageMultiplierWithHistory[],
|
||||
): Map<string, PatchChange[]> {
|
||||
const versionIndex = new Map(versions.map((version, i) => [version, i]));
|
||||
const byVersion = new Map<string, PatchChange[]>();
|
||||
|
||||
const push = (patchVersion: string, change: PatchChange) => {
|
||||
const existing = byVersion.get(patchVersion);
|
||||
if (existing) {
|
||||
existing.push(change);
|
||||
} else {
|
||||
byVersion.set(patchVersion, [change]);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [category, params] of Object.entries(parsed.categories)) {
|
||||
for (const [key, param] of Object.entries(params)) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
param.history,
|
||||
param.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
push(patchVersion, {
|
||||
category,
|
||||
key,
|
||||
from,
|
||||
to,
|
||||
kind: classifyParamChange(category, key, from, to),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const kit of specialPoints ?? []) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
kit.history,
|
||||
kit.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
// Fewer special points needed means the special charges faster.
|
||||
const kind = from === to ? "neutral" : to < from ? "buff" : "nerf";
|
||||
push(patchVersion, {
|
||||
category: SPECIAL_POINTS_PARAM_KEY,
|
||||
key: SPECIAL_POINTS_PARAM_KEY,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
weaponId: kit.weaponId,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const multiplier of damageMultipliers ?? []) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
multiplier.history,
|
||||
multiplier.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
// A higher damage rate means the weapon deals more damage to the object.
|
||||
const kind = from === to ? "neutral" : to > from ? "buff" : "nerf";
|
||||
push(patchVersion, {
|
||||
category: DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: multiplier.target,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const multiplier of incomingDamageMultipliers ?? []) {
|
||||
for (const { patchVersion, from, to } of changesFromHistory(
|
||||
multiplier.history,
|
||||
multiplier.current,
|
||||
versions,
|
||||
versionIndex,
|
||||
)) {
|
||||
// A higher incoming damage rate means the object takes more damage, i.e. a nerf to the
|
||||
// sub or special weapon being defended (the inverse of an outgoing damage multiplier).
|
||||
const kind = from === to ? "neutral" : to > from ? "nerf" : "buff";
|
||||
push(patchVersion, {
|
||||
category: INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY,
|
||||
key: multiplier.target,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
source,
|
||||
attackers: multiplier.attackers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const changes of byVersion.values()) {
|
||||
changes.sort((a, b) => {
|
||||
// Special points first (ordered by kit), then outgoing damage multipliers, then
|
||||
// incoming damage multipliers, then regular params by category and key.
|
||||
const rank = (change: PatchChange) =>
|
||||
change.category === SPECIAL_POINTS_PARAM_KEY
|
||||
? 0
|
||||
: change.category === DAMAGE_MULTIPLIER_PARAM_KEY
|
||||
? 1
|
||||
: change.category === INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY
|
||||
? 2
|
||||
: 3;
|
||||
const aRank = rank(a);
|
||||
const bRank = rank(b);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
|
||||
if (aRank === 0) return (a.weaponId ?? 0) - (b.weaponId ?? 0);
|
||||
if (aRank === 2) {
|
||||
const order =
|
||||
(DAMAGE_RECEIVER_ORDER.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(DAMAGE_RECEIVER_ORDER.get(b.key) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (order !== 0) return order;
|
||||
return attackerGroupKey(a.attackers ?? EMPTY_ATTACKERS).localeCompare(
|
||||
attackerGroupKey(b.attackers ?? EMPTY_ATTACKERS),
|
||||
);
|
||||
}
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
}
|
||||
|
||||
return byVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles per-version change maps into the descending-by-version patch history, attaching each
|
||||
* tracked game version's release date and skipping versions with no changes. When several maps are
|
||||
* given (e.g. a kit's main, sub and special weapon changes) their changes are concatenated in the
|
||||
* order the maps are passed, keeping each map's own within-version ordering.
|
||||
*/
|
||||
function changeMapsToPatches(
|
||||
maps: Array<Map<string, PatchChange[]>>,
|
||||
versions: string[],
|
||||
): WeaponPatch[] {
|
||||
const patchDateByVersion = new Map(PATCHES.map((p) => [p.patch, p.date]));
|
||||
|
||||
return versions
|
||||
.map((version) => ({
|
||||
version,
|
||||
date: patchDateByVersion.get(version) ?? null,
|
||||
changes: maps.flatMap((map) => map.get(version) ?? []),
|
||||
}))
|
||||
.filter((patch) => patch.changes.length > 0)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the descending-by-version patch history of a single weapon, attaching each tracked
|
||||
* game version's release date and skipping versions with no tracked balance changes. Special
|
||||
* points changes are only folded in for main weapons (pass their history as `specialPoints`).
|
||||
*/
|
||||
export function patchHistory(
|
||||
parsed: ParsedWeaponParams | undefined,
|
||||
versions: string[],
|
||||
specialPoints: SpecialPointWithHistory[] = [],
|
||||
damageMultipliers: DamageMultiplierWithHistory[] = [],
|
||||
incomingDamageMultipliers: IncomingDamageMultiplierWithHistory[] = [],
|
||||
): WeaponPatch[] {
|
||||
if (!parsed) return [];
|
||||
|
||||
return changeMapsToPatches(
|
||||
[
|
||||
computeWeaponPatchChanges(
|
||||
parsed,
|
||||
versions,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
undefined,
|
||||
incomingDamageMultipliers,
|
||||
),
|
||||
],
|
||||
versions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a patch history per kit of a main weapon, folding the (shared) main weapon changes
|
||||
* together with the kit's own special points, sub weapon and special weapon changes. Every change
|
||||
* is tagged with its `source` so the patch history can group a column under a divider per weapon.
|
||||
*/
|
||||
export function kitPatchHistories({
|
||||
mainParsed,
|
||||
versions,
|
||||
kits,
|
||||
specialPointsByKit,
|
||||
mainDamageMultipliers,
|
||||
subParams,
|
||||
subDamageMultipliers,
|
||||
subIncomingDamageMultipliers,
|
||||
specialParams,
|
||||
specialDamageMultipliers,
|
||||
specialIncomingDamageMultipliers,
|
||||
}: {
|
||||
mainParsed: ParsedWeaponParams | undefined;
|
||||
versions: string[];
|
||||
kits: WeaponKitInfo[];
|
||||
specialPointsByKit: Record<string, SpecialPointWithHistory>;
|
||||
mainDamageMultipliers: DamageMultiplierWithHistory[];
|
||||
subParams: Record<string, ParsedWeaponParams | undefined>;
|
||||
subDamageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
|
||||
subIncomingDamageMultipliers: Record<
|
||||
string,
|
||||
IncomingDamageMultiplierWithHistory[]
|
||||
>;
|
||||
specialParams: Record<string, ParsedWeaponParams | undefined>;
|
||||
specialDamageMultipliers: Record<string, DamageMultiplierWithHistory[]>;
|
||||
specialIncomingDamageMultipliers: Record<
|
||||
string,
|
||||
IncomingDamageMultiplierWithHistory[]
|
||||
>;
|
||||
}): KitPatchHistory[] {
|
||||
if (!mainParsed) return [];
|
||||
|
||||
return kits.map((kit) => {
|
||||
const kitSpecialPoints = specialPointsByKit[String(kit.weaponId)];
|
||||
const maps = [
|
||||
computeWeaponPatchChanges(
|
||||
mainParsed,
|
||||
versions,
|
||||
kitSpecialPoints ? [kitSpecialPoints] : [],
|
||||
mainDamageMultipliers,
|
||||
"main",
|
||||
),
|
||||
];
|
||||
|
||||
const subIncoming =
|
||||
subIncomingDamageMultipliers[String(kit.subWeaponId)] ?? [];
|
||||
const subParsed = subParams[String(kit.subWeaponId)];
|
||||
if (subParsed || subIncoming.length > 0) {
|
||||
maps.push(
|
||||
computeWeaponPatchChanges(
|
||||
subParsed ?? { weaponId: kit.subWeaponId, categories: {} },
|
||||
versions,
|
||||
[],
|
||||
subDamageMultipliers[String(kit.subWeaponId)] ?? [],
|
||||
"sub",
|
||||
subIncoming,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const specialIncoming =
|
||||
specialIncomingDamageMultipliers[String(kit.specialWeaponId)] ?? [];
|
||||
const specialParsed = specialParams[String(kit.specialWeaponId)];
|
||||
if (specialParsed || specialIncoming.length > 0) {
|
||||
maps.push(
|
||||
computeWeaponPatchChanges(
|
||||
specialParsed ?? { weaponId: kit.specialWeaponId, categories: {} },
|
||||
versions,
|
||||
[],
|
||||
specialDamageMultipliers[String(kit.specialWeaponId)] ?? [],
|
||||
"special",
|
||||
specialIncoming,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
weaponId: kit.weaponId,
|
||||
subWeaponId: kit.subWeaponId,
|
||||
specialWeaponId: kit.specialWeaponId,
|
||||
patches: changeMapsToPatches(maps, versions),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Formats a parameter value for display, trimming trailing zeroes from non-integer numbers. */
|
||||
export function formatValue(value: number | string): string {
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return value.toFixed(4).replace(/\.?0+$/, "");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
186
app/features/params/core/param-directions.ts
Normal file
186
app/features/params/core/param-directions.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Whether a higher value is better ("higher") or worse ("lower") for the player who owns
|
||||
* the weapon. `null` means the direction is unknown / context-dependent.
|
||||
*/
|
||||
type ParamDirection = "higher" | "lower" | null;
|
||||
|
||||
/**
|
||||
* How a value change between two patches affected the weapon: a `"buff"` made it stronger,
|
||||
* a `"nerf"` made it weaker, and `"neutral"` is either an unclassified parameter or a change
|
||||
* whose impact direction we don't track.
|
||||
*/
|
||||
export type ParamChangeKind = "buff" | "nerf" | "neutral";
|
||||
|
||||
/**
|
||||
* Ordered substring rules describing whether a higher value of a parameter is good for its
|
||||
* weapon. The first rule whose `match` is a substring of the full `${category}.${key}` wins,
|
||||
* so narrower exceptions are listed before broader rules (e.g. `ReceiveDamage` before
|
||||
* `Damage`). Parameters matching no rule are treated as having an unknown direction.
|
||||
*/
|
||||
const PARAM_DIRECTION_RULES: Array<{
|
||||
match: string;
|
||||
betterWhenHigher: boolean;
|
||||
}> = [
|
||||
// Taking less damage is good, so these override the broader "Damage" rule below.
|
||||
{ match: "ReceiveDamage", betterWhenHigher: false },
|
||||
{ match: "AttackedDamageRate", betterWhenHigher: false },
|
||||
|
||||
// Lower is better: less ink, faster recovery, tighter spread, shorter delays.
|
||||
{ match: "InkConsume", betterWhenHigher: false },
|
||||
{ match: "InkRecoverStop", betterWhenHigher: false },
|
||||
{ match: "DegSwerve", betterWhenHigher: false },
|
||||
{ match: "DegBias", betterWhenHigher: false },
|
||||
{ match: "ChargeFrame", betterWhenHigher: false },
|
||||
{ match: "RepeatFrame", betterWhenHigher: false },
|
||||
{ match: "PostDelayFrame", betterWhenHigher: false },
|
||||
{ match: "PreDelayFrame", betterWhenHigher: false },
|
||||
{ match: "DashFrame", betterWhenHigher: false },
|
||||
{ match: "NakedFrame", betterWhenHigher: false },
|
||||
{ match: "Dash_ChargeCancelableFrame", betterWhenHigher: false },
|
||||
|
||||
// Higher is better: more damage, durability, mobility, paint, range, uptime.
|
||||
{ match: "Damage", betterWhenHigher: true },
|
||||
{ match: "CanopyHP", betterWhenHigher: true },
|
||||
{ match: "ArmorHP", betterWhenHigher: true },
|
||||
{ match: "MaxFieldHP", betterWhenHigher: true },
|
||||
{ match: "MaxHP", betterWhenHigher: true },
|
||||
{ match: "HitPoint", betterWhenHigher: true },
|
||||
{ match: "MoveSpeed", betterWhenHigher: true },
|
||||
{ match: "WidthHalf", betterWhenHigher: true },
|
||||
{ match: "PaintRadius", betterWhenHigher: true },
|
||||
{ match: "CrossPaint", betterWhenHigher: true },
|
||||
{ match: "PaintHeight", betterWhenHigher: true },
|
||||
{ match: "SpawnNum", betterWhenHigher: true },
|
||||
{ match: "SplitNum", betterWhenHigher: true },
|
||||
{ match: "SpawnSpeed", betterWhenHigher: true },
|
||||
{ match: "GoStraightStateEndMaxSpeed", betterWhenHigher: true },
|
||||
{ match: "MaxShootingFrame", betterWhenHigher: true },
|
||||
{ match: "ServeAreaRadius", betterWhenHigher: true },
|
||||
{ match: "PowerUpFrame", betterWhenHigher: true },
|
||||
{ match: "KnockBackParam.Distance", betterWhenHigher: true },
|
||||
|
||||
// Longer-lasting effects and uptime are buffs.
|
||||
{ match: "SpecialTotalFrame", betterWhenHigher: true },
|
||||
{ match: "SpecialDurationFrame", betterWhenHigher: true },
|
||||
{ match: "MarkingFrame", betterWhenHigher: true },
|
||||
{ match: "RainyFrame", betterWhenHigher: true },
|
||||
{ match: "LaserFrame", betterWhenHigher: true },
|
||||
{ match: ".Low", betterWhenHigher: true },
|
||||
{ match: ".Mid", betterWhenHigher: true },
|
||||
{ match: ".High", betterWhenHigher: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns whether a higher value of the given parameter benefits the weapon's owner, using
|
||||
* substring matching against `${category}.${key}`. Returns `null` when the parameter is not
|
||||
* recognized as clearly directional.
|
||||
*/
|
||||
function getParamDirection(category: string, key: string): ParamDirection {
|
||||
const fullKey = `${category}.${key}`;
|
||||
|
||||
for (const { match, betterWhenHigher } of PARAM_DIRECTION_RULES) {
|
||||
if (fullKey.includes(match)) {
|
||||
return betterWhenHigher ? "higher" : "lower";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Matches a single `"<damage> @ <distance>"` breakpoint of a serialized damage falloff curve. */
|
||||
const DAMAGE_BREAKPOINT_PATTERN = /^\s*([\d.]+)\s*@\s*([\d.]+)\s*$/;
|
||||
|
||||
/**
|
||||
* Parses a serialized damage falloff curve (see `formatDistanceDamageArray`) back into its
|
||||
* breakpoints. Returns `null` for any other string (enums, primitive-array blobs), which are
|
||||
* treated as non-directional.
|
||||
*/
|
||||
function parseDamageCurve(
|
||||
value: number | string,
|
||||
): Array<{ damage: number; distance: number }> | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const breakpoints: Array<{ damage: number; distance: number }> = [];
|
||||
for (const part of value.split(",")) {
|
||||
const match = part.match(DAMAGE_BREAKPOINT_PATTERN);
|
||||
if (!match) return null;
|
||||
breakpoints.push({ damage: Number(match[1]), distance: Number(match[2]) });
|
||||
}
|
||||
|
||||
return breakpoints.length > 0 ? breakpoints : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a change between two damage falloff curves by comparing them breakpoint by
|
||||
* breakpoint. Both more damage and more reach (a higher distance at which a damage tier still
|
||||
* applies) count as improvements, so a curve where every change improves is a buff, every change
|
||||
* worsens is a nerf, and a mix (or curves of differing shape) is neutral. Returns `null` when the
|
||||
* values are not both damage curves, so the caller falls back to scalar comparison.
|
||||
*/
|
||||
function classifyDamageCurveChange(
|
||||
direction: ParamDirection,
|
||||
from: number | string,
|
||||
to: number | string,
|
||||
): ParamChangeKind | null {
|
||||
const fromCurve = parseDamageCurve(from);
|
||||
const toCurve = parseDamageCurve(to);
|
||||
if (!fromCurve || !toCurve || fromCurve.length !== toCurve.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let improved = false;
|
||||
let worsened = false;
|
||||
for (let i = 0; i < fromCurve.length; i++) {
|
||||
for (const field of ["damage", "distance"] as const) {
|
||||
const before = fromCurve[i][field];
|
||||
const after = toCurve[i][field];
|
||||
if (before === after) continue;
|
||||
const isImprovement =
|
||||
direction === "lower" ? after < before : after > before;
|
||||
if (isImprovement) {
|
||||
improved = true;
|
||||
} else {
|
||||
worsened = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (improved && !worsened) return "buff";
|
||||
if (worsened && !improved) return "nerf";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a parameter value change between two patches as a buff, a nerf, or neutral.
|
||||
*
|
||||
* Damage falloff curves are compared breakpoint by breakpoint (see
|
||||
* {@link classifyDamageCurveChange}). Neutral is returned for other non-numeric values, unchanged
|
||||
* values, or parameters whose impact direction is unknown (see {@link getParamDirection}).
|
||||
*/
|
||||
export function classifyParamChange(
|
||||
category: string,
|
||||
key: string,
|
||||
from: number | string,
|
||||
to: number | string,
|
||||
): ParamChangeKind {
|
||||
const direction = getParamDirection(category, key);
|
||||
if (direction === null) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const curveChange = classifyDamageCurveChange(direction, from, to);
|
||||
if (curveChange !== null) {
|
||||
return curveChange;
|
||||
}
|
||||
|
||||
if (typeof from !== "number" || typeof to !== "number" || from === to) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const increased = to > from;
|
||||
const improved = direction === "higher" ? increased : !increased;
|
||||
|
||||
return improved ? "buff" : "nerf";
|
||||
}
|
||||
2115
app/features/params/core/param-explanations.ts
Normal file
2115
app/features/params/core/param-explanations.ts
Normal file
File diff suppressed because it is too large
Load Diff
2169
app/features/params/data/all-version-special-params.json
Normal file
2169
app/features/params/data/all-version-special-params.json
Normal file
File diff suppressed because it is too large
Load Diff
1052
app/features/params/data/all-version-sub-params.json
Normal file
1052
app/features/params/data/all-version-sub-params.json
Normal file
File diff suppressed because it is too large
Load Diff
27242
app/features/params/data/all-version-weapon-params.json
Normal file
27242
app/features/params/data/all-version-weapon-params.json
Normal file
File diff suppressed because it is too large
Load Diff
837
app/features/params/data/damage-rate-history.json
Normal file
837
app/features/params/data/damage-rate-history.json
Normal file
|
|
@ -0,0 +1,837 @@
|
|||
{
|
||||
"metadata": {
|
||||
"versions": [
|
||||
"0.9.9",
|
||||
"1.0.0",
|
||||
"1.1.0",
|
||||
"1.1.1",
|
||||
"1.2.0",
|
||||
"2.0.0",
|
||||
"2.1.0",
|
||||
"3.0.0",
|
||||
"3.1.0",
|
||||
"4.0.0",
|
||||
"4.1.0",
|
||||
"5.0.0",
|
||||
"5.1.0",
|
||||
"5.2.0",
|
||||
"6.0.0",
|
||||
"6.1.0",
|
||||
"7.0.0",
|
||||
"7.1.0",
|
||||
"7.2.0",
|
||||
"8.0.0",
|
||||
"8.1.0",
|
||||
"9.0.0",
|
||||
"9.1.0",
|
||||
"9.2.0",
|
||||
"9.3.0",
|
||||
"10.0.0",
|
||||
"10.1.0",
|
||||
"11.0.0",
|
||||
"11.0.1",
|
||||
"11.1.0",
|
||||
"11.2.0"
|
||||
]
|
||||
},
|
||||
"rows": {
|
||||
"Blaster_BlasterShort": {
|
||||
"mainWeaponIds": [200, 201, 205],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.2,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BlowerExhale_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [8],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 4.2,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 5.6
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 5.6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 2.1,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 2.8
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Bomb_DirectHit": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [2, 7, 0],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 1.5
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Bomb_TorpedoSplashBurst": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [13],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1,
|
||||
"history": [
|
||||
{
|
||||
"version": "0.9.9",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"value": 0.25
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BrushSplash": {
|
||||
"mainWeaponIds": [1100, 1101, 1110, 1111, 1112, 1115],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 1.98,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 1.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Castle": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [17],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 2.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 2.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Jetpack_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [10],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.8
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.365,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.575
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.05
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Jetpack_Bullet": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [10],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 2.0835,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 0.8334
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.8
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.365,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2.1
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.575
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.05
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"MultiMissile_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [4],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 0.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 0.375,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 0.75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"MultiMissile_Bullet": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [4],
|
||||
"targets": [
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 0.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 0.375,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 0.75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"NiceBall": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [6],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 4,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 2,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"version": "9.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 2,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"version": "9.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RollerSplash_Compact": {
|
||||
"mainWeaponIds": [1000, 1001, 1002],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.64,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 2.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RollerSplash": {
|
||||
"mainWeaponIds": [1010, 1011, 1015],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.64,
|
||||
"history": [
|
||||
{
|
||||
"version": "7.2.0",
|
||||
"value": 2.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_ChargeShot": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 2.6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "ShockSonar",
|
||||
"current": 2.4,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 4.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_ChargeSlash": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 0.455,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 0.65
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_Shot": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 1.82,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 2.6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "ShockSonar",
|
||||
"current": 2.4,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 4.8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Saber_Slash": {
|
||||
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [],
|
||||
"targets": [
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 0.91,
|
||||
"history": [
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1.3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"TripleTornado": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [14],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.3.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraShot": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [1],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 1.8,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.3,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.5
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 0.975,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 1.5
|
||||
},
|
||||
{
|
||||
"version": "8.0.0",
|
||||
"value": 1.125
|
||||
},
|
||||
{
|
||||
"version": "11.1.0",
|
||||
"value": 0.75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraStamp_Swing": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [11],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 4.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.5,
|
||||
"history": [
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraStamp_Throw_BombCore": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [11],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Bomb_TorpedoBullet",
|
||||
"current": 0.545,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyCompact",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyNormal",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyNormal_Launched",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletShelterCanopyFocus",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletShelterCanopyFocus_Launched",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyWide",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "BulletUmbrellaCanopyWide_Launched",
|
||||
"current": 4.091,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3.273,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Gachihoko_Barrier",
|
||||
"current": 1.909,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_Barrier",
|
||||
"current": 1.909,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "GreatBarrier_WeakPoint",
|
||||
"current": 1.4318,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 2.625
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "NiceBall_Armor",
|
||||
"current": 1.364,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 2.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "ShockSonar",
|
||||
"current": 2.182,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Wsb_Flag",
|
||||
"current": 1.636,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Wsb_Shield",
|
||||
"current": 2.182,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "Wsb_Sprinkler",
|
||||
"current": 1.636,
|
||||
"history": [
|
||||
{
|
||||
"version": "9.2.0",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"UltraStamp_Throw": {
|
||||
"mainWeaponIds": [],
|
||||
"subWeaponIds": [],
|
||||
"specialWeaponIds": [11],
|
||||
"targets": [
|
||||
{
|
||||
"target": "Chariot",
|
||||
"current": 3.273,
|
||||
"history": [
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"value": 1.091
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
306
app/features/params/loaders/params.$slug.server.ts
Normal file
306
app/features/params/loaders/params.$slug.server.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponIdToBaseWeaponId,
|
||||
weaponIdToType,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
specialWeaponNameSlugToId,
|
||||
subWeaponNameSlugToId,
|
||||
weaponNameSlugToId,
|
||||
} from "~/utils/unslugify.server";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import * as WeaponParams from "../core/WeaponParams";
|
||||
import specialWeaponParamsData from "../data/all-version-special-params.json";
|
||||
import subWeaponParamsData from "../data/all-version-sub-params.json";
|
||||
import weaponParamsData from "../data/all-version-weapon-params.json";
|
||||
import damageRateHistoryData from "../data/damage-rate-history.json";
|
||||
import type {
|
||||
DamageMultiplierWithHistory,
|
||||
IncomingDamageMultiplierWithHistory,
|
||||
SpecialPointWithHistory,
|
||||
WeaponParamKind,
|
||||
} from "../weapon-params-types";
|
||||
|
||||
const mainParamsData = weaponParamsData as WeaponParams.AllVersionParams;
|
||||
const subParamsData = subWeaponParamsData as WeaponParams.AllVersionParams;
|
||||
const specialParamsData =
|
||||
specialWeaponParamsData as WeaponParams.AllVersionParams;
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["weapons"], {
|
||||
lng: "en",
|
||||
});
|
||||
|
||||
const mainWeaponId = weaponNameSlugToId(params.slug);
|
||||
if (typeof mainWeaponId === "number") {
|
||||
if (weaponIdToType(mainWeaponId) === "ALT_SKIN") {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
return mainWeaponData(
|
||||
mainWeaponId,
|
||||
t(`weapons:MAIN_${mainWeaponId}`),
|
||||
mySlugify(t(`weapons:MAIN_${mainWeaponId}`, { lng: "en" })),
|
||||
);
|
||||
}
|
||||
|
||||
const subWeaponId = subWeaponNameSlugToId(params.slug);
|
||||
if (typeof subWeaponId === "number") {
|
||||
return subWeaponData(
|
||||
subWeaponId,
|
||||
t(`weapons:SUB_${subWeaponId}`),
|
||||
mySlugify(t(`weapons:SUB_${subWeaponId}`, { lng: "en" })),
|
||||
);
|
||||
}
|
||||
|
||||
const specialWeaponId = specialWeaponNameSlugToId(params.slug);
|
||||
if (typeof specialWeaponId === "number") {
|
||||
return specialWeaponData(
|
||||
specialWeaponId,
|
||||
t(`weapons:SPECIAL_${specialWeaponId}`),
|
||||
mySlugify(t(`weapons:SPECIAL_${specialWeaponId}`, { lng: "en" })),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Response(null, { status: 404 });
|
||||
};
|
||||
|
||||
function damageMultipliersByWeapon(
|
||||
weaponIds: number[],
|
||||
kind: WeaponParamKind,
|
||||
): Record<string, DamageMultiplierWithHistory[]> {
|
||||
const rows = damageRateHistoryData.rows as Parameters<
|
||||
typeof WeaponParams.damageMultipliersForWeapon
|
||||
>[0];
|
||||
|
||||
const result: Record<string, DamageMultiplierWithHistory[]> = {};
|
||||
for (const id of weaponIds) {
|
||||
const multipliers = WeaponParams.damageMultipliersForWeapon(rows, id, kind);
|
||||
if (multipliers.length > 0) {
|
||||
result[String(id)] = multipliers;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function incomingDamageMultipliersByWeapon(
|
||||
weaponIds: number[],
|
||||
kind: "sub" | "special",
|
||||
): Record<string, IncomingDamageMultiplierWithHistory[]> {
|
||||
const rows = damageRateHistoryData.rows as Parameters<
|
||||
typeof WeaponParams.incomingDamageMultipliersForWeapon
|
||||
>[0];
|
||||
|
||||
const result: Record<string, IncomingDamageMultiplierWithHistory[]> = {};
|
||||
for (const id of weaponIds) {
|
||||
const multipliers = WeaponParams.incomingDamageMultipliersForWeapon(
|
||||
rows,
|
||||
id,
|
||||
kind,
|
||||
);
|
||||
if (multipliers.length > 0) {
|
||||
result[String(id)] = multipliers;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mainWeaponData(
|
||||
weaponId: MainWeaponId,
|
||||
weaponName: string,
|
||||
slug: string,
|
||||
) {
|
||||
const categoryWeaponIds = WeaponParams.categoryWeaponIds(weaponId);
|
||||
|
||||
const kits = WeaponParams.kitSiblingIds(weaponId).map((id) => {
|
||||
const { subWeaponId, specialWeaponId } = mainWeaponParams(id);
|
||||
return { weaponId: id, subWeaponId, specialWeaponId };
|
||||
});
|
||||
|
||||
const versions = mainParamsData.metadata.versions;
|
||||
|
||||
const weaponParams = WeaponParams.parseMany(
|
||||
categoryWeaponIds,
|
||||
mainParamsData,
|
||||
weaponIdToBaseWeaponId,
|
||||
);
|
||||
|
||||
const allSpecialPoints = mainParamsData.specialPoints;
|
||||
|
||||
const specialPoints: Record<string, SpecialPointWithHistory[]> = {};
|
||||
for (const id of categoryWeaponIds) {
|
||||
specialPoints[String(id)] = WeaponParams.kitSiblingIds(id).map((kitId) => ({
|
||||
weaponId: kitId,
|
||||
current: mainWeaponParams(kitId).SpecialPoint,
|
||||
history: allSpecialPoints?.[String(kitId)]?.history ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
const damageMultipliers = damageMultipliersByWeapon(
|
||||
categoryWeaponIds,
|
||||
"main",
|
||||
);
|
||||
|
||||
const patchHistory = WeaponParams.patchHistory(
|
||||
weaponParams[String(weaponId)],
|
||||
versions,
|
||||
specialPoints[String(weaponId)] ?? [],
|
||||
damageMultipliers[String(weaponId)] ?? [],
|
||||
);
|
||||
|
||||
const subParams = WeaponParams.parseMany(
|
||||
kits.map((kit) => kit.subWeaponId),
|
||||
subParamsData,
|
||||
);
|
||||
|
||||
const specialParams = WeaponParams.parseMany(
|
||||
kits.map((kit) => kit.specialWeaponId),
|
||||
specialParamsData,
|
||||
);
|
||||
|
||||
const specialPointsByKit: Record<string, SpecialPointWithHistory> = {};
|
||||
for (const { weaponId: kitId } of kits) {
|
||||
specialPointsByKit[String(kitId)] = {
|
||||
weaponId: kitId,
|
||||
current: mainWeaponParams(kitId).SpecialPoint,
|
||||
history: allSpecialPoints?.[String(kitId)]?.history ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const kitPatchHistories = WeaponParams.kitPatchHistories({
|
||||
mainParsed: weaponParams[String(weaponId)],
|
||||
versions,
|
||||
kits,
|
||||
specialPointsByKit,
|
||||
mainDamageMultipliers: damageMultipliers[String(weaponId)] ?? [],
|
||||
subParams,
|
||||
subDamageMultipliers: damageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.subWeaponId),
|
||||
"sub",
|
||||
),
|
||||
subIncomingDamageMultipliers: incomingDamageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.subWeaponId),
|
||||
"sub",
|
||||
),
|
||||
specialParams,
|
||||
specialDamageMultipliers: damageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.specialWeaponId),
|
||||
"special",
|
||||
),
|
||||
specialIncomingDamageMultipliers: incomingDamageMultipliersByWeapon(
|
||||
kits.map((kit) => kit.specialWeaponId),
|
||||
"special",
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "main" as WeaponParamKind,
|
||||
weaponId,
|
||||
weaponName,
|
||||
slug,
|
||||
categoryWeaponIds,
|
||||
kits,
|
||||
weaponParams,
|
||||
specialPoints,
|
||||
damageMultipliers,
|
||||
patchHistory,
|
||||
kitPatchHistories,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
function subWeaponData(
|
||||
weaponId: SubWeaponId,
|
||||
weaponName: string,
|
||||
slug: string,
|
||||
) {
|
||||
const versions = subParamsData.metadata.versions;
|
||||
|
||||
const weaponParams = WeaponParams.parseMany(subWeaponIds, subParamsData);
|
||||
|
||||
const damageMultipliers = damageMultipliersByWeapon([...subWeaponIds], "sub");
|
||||
|
||||
const incomingDamageMultipliers = incomingDamageMultipliersByWeapon(
|
||||
[...subWeaponIds],
|
||||
"sub",
|
||||
);
|
||||
|
||||
const patchHistory = WeaponParams.patchHistory(
|
||||
weaponParams[String(weaponId)],
|
||||
versions,
|
||||
[],
|
||||
damageMultipliers[String(weaponId)] ?? [],
|
||||
incomingDamageMultipliers[String(weaponId)] ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "sub" as WeaponParamKind,
|
||||
weaponId,
|
||||
weaponName,
|
||||
slug,
|
||||
categoryWeaponIds: [...subWeaponIds],
|
||||
kits: undefined,
|
||||
weaponParams,
|
||||
specialPoints: undefined,
|
||||
damageMultipliers,
|
||||
patchHistory,
|
||||
kitPatchHistories: undefined,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
function specialWeaponData(
|
||||
weaponId: SpecialWeaponId,
|
||||
weaponName: string,
|
||||
slug: string,
|
||||
) {
|
||||
const versions = specialParamsData.metadata.versions;
|
||||
|
||||
const weaponParams = WeaponParams.parseMany(
|
||||
specialWeaponIds,
|
||||
specialParamsData,
|
||||
);
|
||||
|
||||
const damageMultipliers = damageMultipliersByWeapon(
|
||||
[...specialWeaponIds],
|
||||
"special",
|
||||
);
|
||||
|
||||
const incomingDamageMultipliers = incomingDamageMultipliersByWeapon(
|
||||
[...specialWeaponIds],
|
||||
"special",
|
||||
);
|
||||
|
||||
const patchHistory = WeaponParams.patchHistory(
|
||||
weaponParams[String(weaponId)],
|
||||
versions,
|
||||
[],
|
||||
damageMultipliers[String(weaponId)] ?? [],
|
||||
incomingDamageMultipliers[String(weaponId)] ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "special" as WeaponParamKind,
|
||||
weaponId,
|
||||
weaponName,
|
||||
slug,
|
||||
categoryWeaponIds: [...specialWeaponIds],
|
||||
kits: undefined,
|
||||
weaponParams,
|
||||
specialPoints: undefined,
|
||||
damageMultipliers,
|
||||
patchHistory,
|
||||
kitPatchHistories: undefined,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
78
app/features/params/routes/params.$slug.tsx
Normal file
78
app/features/params/routes/params.$slug.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
outlinedMainWeaponImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
weaponParamsPage,
|
||||
} from "~/utils/urls";
|
||||
import { WeaponParamsView } from "../components/WeaponParamsView";
|
||||
import { loader } from "../loaders/params.$slug.server";
|
||||
import type { WeaponParamKind } from "../weapon-params-types";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["weapons", "common", "analyzer", "params"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.data as SerializeFrom<typeof loader> | undefined;
|
||||
if (!data) return [];
|
||||
return [
|
||||
{
|
||||
imgPath: weaponImageUrl(data.kind, data.weaponId),
|
||||
href: weaponParamsPage(data.slug),
|
||||
type: "IMAGE",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (args) => {
|
||||
if (!args.data) return [];
|
||||
return metaTags({
|
||||
title: `${args.data.weaponName} parameters`,
|
||||
description: `${args.data.weaponName} parameters with version history compared across ${comparedAcross(args.data.kind)}.`,
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function WeaponParamsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<WeaponParamsView
|
||||
kind={data.kind}
|
||||
weaponId={data.weaponId}
|
||||
categoryWeaponIds={data.categoryWeaponIds}
|
||||
weaponParams={data.weaponParams}
|
||||
specialPoints={data.specialPoints}
|
||||
damageMultipliers={data.damageMultipliers}
|
||||
versions={data.versions}
|
||||
patchHistory={data.patchHistory}
|
||||
kitPatchHistories={data.kitPatchHistories}
|
||||
kits={data.kits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function weaponImageUrl(kind: WeaponParamKind, weaponId: number) {
|
||||
if (kind === "sub") return subWeaponImageUrl(weaponId as SubWeaponId);
|
||||
if (kind === "special") {
|
||||
return specialWeaponImageUrl(weaponId as SpecialWeaponId);
|
||||
}
|
||||
return outlinedMainWeaponImageUrl(weaponId as MainWeaponId);
|
||||
}
|
||||
|
||||
function comparedAcross(kind: WeaponParamKind) {
|
||||
if (kind === "sub") return "all sub weapons";
|
||||
if (kind === "special") return "all special weapons";
|
||||
return "the weapon's category";
|
||||
}
|
||||
48
app/features/params/weapon-params-constants.ts
Normal file
48
app/features/params/weapon-params-constants.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
|
||||
|
||||
/**
|
||||
* Sentinel `category` used for the special points entry in patch change data, since special
|
||||
* points are not a regular weapon parameter. The matching `key` is also this value.
|
||||
*/
|
||||
export const SPECIAL_POINTS_PARAM_KEY = "__specialPoints__";
|
||||
|
||||
/**
|
||||
* Sentinel `category` used for damage multiplier (damage rate vs objects) entries in patch
|
||||
* change data. The `key` of such a change holds the damage receiver target instead.
|
||||
*/
|
||||
export const DAMAGE_MULTIPLIER_PARAM_KEY = "__damageMultiplier__";
|
||||
|
||||
/**
|
||||
* Sentinel `category` used for incoming damage multiplier entries in patch change data: a change
|
||||
* to some *other* weapon's damage rate against the page's sub or special weapon (which is itself a
|
||||
* damageable object). The `key` holds the damage receiver target, and `attackers` holds the
|
||||
* weapons whose rate changed.
|
||||
*/
|
||||
export const INCOMING_DAMAGE_MULTIPLIER_PARAM_KEY =
|
||||
"__incomingDamageMultiplier__";
|
||||
|
||||
/**
|
||||
* Maps a sub or special weapon to the object {@link DAMAGE_RECEIVERS} target(s) that represent it,
|
||||
* so the patch history can surface changes to other weapons' damage rates *against* the kit's sub
|
||||
* or special. Only weapons that exist as a damageable object are listed.
|
||||
*/
|
||||
export const INCOMING_DAMAGE_RECEIVERS: Record<
|
||||
"sub" | "special",
|
||||
Record<number, readonly DamageReceiver[]>
|
||||
> = {
|
||||
special: {
|
||||
2: ["GreatBarrier_Barrier", "GreatBarrier_WeakPoint"], // Big Bubbler
|
||||
6: ["NiceBall_Armor"], // Booyah Bomb
|
||||
7: ["ShockSonar"], // Wave Breaker
|
||||
8: ["BlowerInhale"], // Ink Vac
|
||||
12: ["Chariot"], // Crab Tank
|
||||
16: ["Decoy"], // Super Chump
|
||||
18: ["BulletPogo"], // Triple Splashdown
|
||||
},
|
||||
sub: {
|
||||
3: ["Wsb_Sprinkler"], // Sprinkler
|
||||
4: ["Wsb_Shield"], // Splash Wall
|
||||
8: ["Wsb_Flag"], // Squid Beakon
|
||||
13: ["Bomb_TorpedoBullet"], // Torpedo
|
||||
},
|
||||
};
|
||||
136
app/features/params/weapon-params-types.ts
Normal file
136
app/features/params/weapon-params-types.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { ParamChangeKind } from "./core/param-directions";
|
||||
|
||||
export interface WeaponKitInfo {
|
||||
weaponId: MainWeaponId;
|
||||
subWeaponId: SubWeaponId;
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}
|
||||
|
||||
export interface ParamValueWithHistory {
|
||||
current: number | string;
|
||||
history: Array<{ version: string; value: number | string }>;
|
||||
}
|
||||
|
||||
/** Which set of weapons a params page compares: main weapons, sub weapons or special weapons. */
|
||||
export type WeaponParamKind = "main" | "sub" | "special";
|
||||
|
||||
const WEAPON_PARAM_KIND_KEY_PREFIX: Record<WeaponParamKind, string> = {
|
||||
main: "MAIN",
|
||||
sub: "SUB",
|
||||
special: "SPECIAL",
|
||||
};
|
||||
|
||||
/** The i18next `weapons` namespace key for a weapon of the given {@link WeaponParamKind}. */
|
||||
export function weaponTranslationKey(kind: WeaponParamKind, id: number) {
|
||||
return `weapons:${WEAPON_PARAM_KIND_KEY_PREFIX[kind]}_${id}`;
|
||||
}
|
||||
|
||||
export interface ParsedWeaponParams {
|
||||
weaponId: number;
|
||||
categories: Record<string, Record<string, ParamValueWithHistory>>;
|
||||
}
|
||||
|
||||
export interface ParamDefinition {
|
||||
category: string;
|
||||
key: string;
|
||||
fullKey: string;
|
||||
}
|
||||
|
||||
/** A single weapon's numeric value for one parameter, used by the cross-weapon comparison chart. */
|
||||
export interface ParamComparisonEntry {
|
||||
weaponId: number;
|
||||
value: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpecialPointWithHistory {
|
||||
weaponId: MainWeaponId;
|
||||
current: number;
|
||||
history: Array<{ version: string; value: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* History of a weapon's damage multiplier against a single object (a {@link DAMAGE_RECEIVERS}
|
||||
* target), surfaced only in the patch history. `target` is the receiver key used by the object
|
||||
* damage calculator.
|
||||
*/
|
||||
export interface DamageMultiplierWithHistory {
|
||||
target: string;
|
||||
current: number;
|
||||
history: Array<{ version: string; value: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of weapons whose damage rate against an object changed together. Carried by an incoming
|
||||
* damage multiplier {@link PatchChange} so the badge can show the attacking weapons' icons.
|
||||
*/
|
||||
export interface IncomingDamageAttackers {
|
||||
mainWeaponIds: MainWeaponId[];
|
||||
subWeaponIds: SubWeaponId[];
|
||||
specialWeaponIds: SpecialWeaponId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* History of some attacking weapons' shared damage multiplier against a single object
|
||||
* ({@link DamageMultiplierWithHistory} from the defender's perspective): the page's sub or special
|
||||
* weapon is the object being damaged. `target` is the receiver key used by the object damage
|
||||
* calculator.
|
||||
*/
|
||||
export interface IncomingDamageMultiplierWithHistory {
|
||||
target: string;
|
||||
attackers: IncomingDamageAttackers;
|
||||
current: number;
|
||||
history: Array<{ version: string; value: number }>;
|
||||
}
|
||||
|
||||
export interface PatchChange {
|
||||
category: string;
|
||||
key: string;
|
||||
from: number | string;
|
||||
to: number | string;
|
||||
kind: ParamChangeKind;
|
||||
/** The specific kit a special points change belongs to. Only set for special points. */
|
||||
weaponId?: MainWeaponId;
|
||||
/**
|
||||
* Which weapon of a kit the change belongs to. Used by the per-kit patch history to group a
|
||||
* column's changes under a divider per weapon. Only set for kit patch histories.
|
||||
*/
|
||||
source?: WeaponParamKind;
|
||||
/** The weapons whose rate changed. Only set for incoming damage multiplier changes. */
|
||||
attackers?: IncomingDamageAttackers;
|
||||
}
|
||||
|
||||
export interface WeaponPatch {
|
||||
version: string;
|
||||
date: string | null;
|
||||
changes: PatchChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch history of a single main weapon kit, folding the main weapon's changes together with its
|
||||
* sub and special weapon's changes. Each {@link PatchChange} carries a `source` so the changes can
|
||||
* be grouped per weapon within a patch.
|
||||
*/
|
||||
export interface KitPatchHistory {
|
||||
weaponId: MainWeaponId;
|
||||
subWeaponId: SubWeaponId;
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
patches: WeaponPatch[];
|
||||
}
|
||||
|
||||
export interface WeaponParamsTableProps {
|
||||
kind: WeaponParamKind;
|
||||
currentWeaponId: number;
|
||||
categoryWeaponIds: number[];
|
||||
weaponParams: Record<string, ParsedWeaponParams>;
|
||||
/** Special points are only tracked for main weapons. */
|
||||
specialPoints?: Record<string, SpecialPointWithHistory[]>;
|
||||
/** Damage multipliers (damage rate vs objects), keyed by weapon id. */
|
||||
damageMultipliers?: Record<string, DamageMultiplierWithHistory[]>;
|
||||
versions: string[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
119
e2e/params.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1
knip.ts
1
knip.ts
|
|
@ -4,6 +4,7 @@ const config = {
|
|||
type: true,
|
||||
},
|
||||
tags: ["-lintignore"],
|
||||
ignore: ["scripts/dicts/**"],
|
||||
entry: [
|
||||
"app/features/*/routes/**/*.{ts,tsx}",
|
||||
"migrations/**/*.js",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Vægt",
|
||||
"attribute.weight.Fast": "Let",
|
||||
"attribute.weight.Slow": "Tung",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/da/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Gewicht",
|
||||
"attribute.weight.Fast": "Leicht",
|
||||
"attribute.weight.Slow": "Schwer",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/de/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "Weapon to analyze",
|
||||
"rawParameters": "Raw parameters",
|
||||
"attribute.weight": "Weight",
|
||||
"attribute.weight.Fast": "Light",
|
||||
"attribute.weight.Slow": "Heavy",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/en/params.json
Normal 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"
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "Arma a analizar",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Peso",
|
||||
"attribute.weight.Fast": "Ligero",
|
||||
"attribute.weight.Slow": "Pesado",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/es-ES/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Peso",
|
||||
"attribute.weight.Fast": "Ligero",
|
||||
"attribute.weight.Slow": "Pesado",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/es-US/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Poids",
|
||||
"attribute.weight.Fast": "Léger",
|
||||
"attribute.weight.Slow": "Lourd",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/fr-CA/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Poids",
|
||||
"attribute.weight.Fast": "Léger",
|
||||
"attribute.weight.Slow": "Lourd",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/fr-EU/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "משקל",
|
||||
"attribute.weight.Fast": "קל",
|
||||
"attribute.weight.Slow": "כבד",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/he/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Peso",
|
||||
"attribute.weight.Fast": "Leggero",
|
||||
"attribute.weight.Slow": "Pesante",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/it/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "武器",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "重さ",
|
||||
"attribute.weight.Fast": "軽量級",
|
||||
"attribute.weight.Slow": "重量級",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/ja/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "무게",
|
||||
"attribute.weight.Fast": "가벼움",
|
||||
"attribute.weight.Slow": "무거움",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/ko/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Gewicht",
|
||||
"attribute.weight.Fast": "Licht",
|
||||
"attribute.weight.Slow": "Zwaar",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/nl/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Waga",
|
||||
"attribute.weight.Fast": "Lekka",
|
||||
"attribute.weight.Slow": "Ciężka",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/pl/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Peso",
|
||||
"attribute.weight.Fast": "Leve",
|
||||
"attribute.weight.Slow": "Pesado",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/pt-BR/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "Весовая категория",
|
||||
"attribute.weight.Fast": "Лёгкая",
|
||||
"attribute.weight.Slow": "Тяжелая",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/ru/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"weaponSelect.label": "要分析的武器",
|
||||
"rawParameters": "",
|
||||
"attribute.weight": "量级",
|
||||
"attribute.weight.Fast": "轻量级",
|
||||
"attribute.weight.Slow": "重量级",
|
||||
|
|
|
|||
|
|
@ -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
14
locales/zh/params.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"..",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user